Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3058988a77 | ||
|
|
a91bb4b3c2 | ||
|
|
0cd95c69f0 | ||
|
|
920ba34b4b | ||
|
|
84a75ed2a4 | ||
|
|
d06d87146d | ||
|
|
6a0e6c1c0c | ||
|
|
b81d913ae6 | ||
|
|
fda71e5e5c | ||
|
|
2d7ae1af8f | ||
|
|
2e9b732914 | ||
|
|
423b24d4ee | ||
|
|
14c1e3bb9e | ||
|
|
d1a80accbe | ||
|
|
6a5b3cadbc | ||
|
|
e0f3d82239 | ||
|
|
ecb385d738 | ||
|
|
7a691edd3a | ||
|
|
35c7662f89 | ||
|
|
69367aa786 | ||
|
|
8563b90918 | ||
|
|
3218fc3c1b | ||
|
|
ea5cc6fc35 | ||
|
|
176a3d4fdf | ||
|
|
8f8f7dff23 | ||
|
|
bfc79c64d9 | ||
|
|
1601cb1743 | ||
|
|
873b19566a | ||
|
|
b0753e2aac | ||
|
|
a69c0e50dc | ||
|
|
72214ae989 | ||
|
|
5c606fd1d4 | ||
|
|
372faebf28 | ||
|
|
675466a8c3 | ||
|
|
cd49b50c40 | ||
|
|
41eefc2f8c | ||
|
|
c3946e5d72 | ||
|
|
524295926f | ||
|
|
bff5b7f0e4 | ||
|
|
cef22a2526 | ||
|
|
deef9421a2 | ||
|
|
3c19e9ee93 | ||
|
|
3d94f87bb0 | ||
|
|
4644f25edd | ||
|
|
c58e0a571e | ||
|
|
332fbaa9b1 | ||
|
|
7a0c219f96 | ||
|
|
985423ae61 | ||
|
|
cfacda2625 | ||
|
|
bf6beec465 | ||
|
|
9f3aa91e81 | ||
|
|
4bdcef3f73 | ||
|
|
8ddf8b7005 | ||
|
|
8bbb1b19ae | ||
|
|
e32ca00aed | ||
|
|
1219ec8229 | ||
|
|
e324f5d821 | ||
|
|
6f1a05366c | ||
|
|
0a1dc5f93f | ||
|
|
ae0b384497 | ||
|
|
f3875a6b60 | ||
|
|
f09c5ebdec | ||
|
|
6d2e4c299a | ||
|
|
b654b320a1 | ||
|
|
4cc8588afb | ||
|
|
584aa2d7d7 | ||
|
|
c9dd07f919 | ||
|
|
404b4409e7 | ||
|
|
6e6cd5e26d | ||
|
|
e658a14296 | ||
|
|
b1546ed28a |
@@ -0,0 +1,6 @@
|
|||||||
|
# Release 57: config and provisioning
|
||||||
|
|
||||||
|
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
|
||||||
|
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
|
||||||
|
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
|
||||||
|
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **The build agent says whether it is busy, and being asked drains it** (novox/hq ADR 0335 §1, §5). Its
|
||||||
|
// container declares `busy` as `mesh-builder busy`: the node-engine runs it inside the container before it
|
||||||
|
// replaces it. It exits 0 while a build is in hand and 1 when none is; and from the first ask the agent takes
|
||||||
|
// no new build request, so its answer ends once the build in hand ends — a replaced builder finishes the check
|
||||||
|
// it runs, instead of the build queue handing it out again from the start (issue 594).
|
||||||
|
//
|
||||||
|
// Two marks in the container's own temporary directory, as the ready mark (ready.go): a new container starts
|
||||||
|
// without them, and a restarted one removes them before it takes work again.
|
||||||
|
//
|
||||||
|
// - in hand: written by the agent when it claims a request, before it reads whether it is drained; removed
|
||||||
|
// when the build ends.
|
||||||
|
// - drained: written by the ask, before it reads whether a build is in hand.
|
||||||
|
//
|
||||||
|
// Each side writes its own mark before it reads the other's, so an ask racing a fetch sees the build in hand,
|
||||||
|
// or the agent sees the drain and hands the request back; never neither.
|
||||||
|
|
||||||
|
func inHandMark() string { return filepath.Join(os.TempDir(), "mesh-builder.in-hand") }
|
||||||
|
func drainMark() string { return filepath.Join(os.TempDir(), "mesh-builder.drained") }
|
||||||
|
|
||||||
|
// The drain lapses when no ask has come for drainLapse: the node-engine asks every 20 seconds for as long as it
|
||||||
|
// really waits, so an agent whose replacement was withdrawn — the declaration put back, the walk stopped —
|
||||||
|
// takes work again rather than staying drained for ever (review of ADR 0335). And an agent drained for longer
|
||||||
|
// than the node-engine's bound and the slack says it is not ready: the replacement it was drained for never
|
||||||
|
// came, which is a fault to see.
|
||||||
|
const (
|
||||||
|
drainLapse = 2 * time.Minute
|
||||||
|
drainTooLong = 15*time.Minute + 5*time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// drainedAt is when the drain began and when it was last asked, from its mark; false when there is no mark.
|
||||||
|
func drainedAt() (began, asked time.Time, ok bool) {
|
||||||
|
info, err := os.Stat(drainMark())
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, false
|
||||||
|
}
|
||||||
|
asked = info.ModTime()
|
||||||
|
began = asked
|
||||||
|
if raw, err := os.ReadFile(drainMark()); err == nil {
|
||||||
|
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(string(raw))); err == nil && !t.After(asked) {
|
||||||
|
began = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return began, asked, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// drained says the agent was asked whether it is busy within drainLapse: it takes no new build request.
|
||||||
|
func drained() bool { return drainedSince(time.Now()) }
|
||||||
|
|
||||||
|
func drainedSince(now time.Time) bool {
|
||||||
|
_, asked, ok := drainedAt()
|
||||||
|
return ok && now.Sub(asked) < drainLapse
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainedTooLong is the health check's second half: nil unless the agent has been drained, asked all along, for
|
||||||
|
// longer than its node-engine's bound and the slack.
|
||||||
|
func drainedTooLong(now time.Time) error {
|
||||||
|
began, asked, ok := drainedAt()
|
||||||
|
if !ok || now.Sub(asked) >= drainLapse || now.Sub(began) <= drainTooLong {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("drained since %s and still asked: the replacement its node-engine waits for has not come "+
|
||||||
|
"within %s (novox/hq ADR 0335)", began.UTC().Format(time.RFC3339), drainTooLong)
|
||||||
|
}
|
||||||
|
|
||||||
|
// claim marks a request in hand, unless the agent is drained: then nothing is in hand, and the request goes
|
||||||
|
// back to the queue.
|
||||||
|
func claim() bool {
|
||||||
|
// Made, never written over: one build at a time, and a mark already there is a build in hand.
|
||||||
|
f, err := os.OpenFile(inHandMark(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
// Unmarked, an ask would say idle while this build runs: the build is not taken.
|
||||||
|
fmt.Fprintf(os.Stderr, "cannot mark a build in hand (%s), so it is handed back: %v\n", inHandMark(), err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err = f.WriteString(time.Now().UTC().Format(time.RFC3339) + "\n")
|
||||||
|
if closeErr := f.Close(); err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(inHandMark())
|
||||||
|
fmt.Fprintf(os.Stderr, "cannot mark a build in hand (%s), so it is handed back: %v\n", inHandMark(), err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if drained() {
|
||||||
|
_ = os.Remove(inHandMark())
|
||||||
|
fmt.Fprintln(os.Stderr, "asked whether busy, so this agent takes no new build: handed back")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// holding says what is in hand, for the ask to print: written once the request is read.
|
||||||
|
func holding(what string) {
|
||||||
|
_ = os.WriteFile(inHandMark(), []byte(time.Now().UTC().Format(time.RFC3339)+" "+what+"\n"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// released ends the build in hand.
|
||||||
|
func released() {
|
||||||
|
if err := os.Remove(inHandMark()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
fmt.Fprintf(os.Stderr, "cannot remove the mark of the build in hand (%s): %v\n", inHandMark(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// undrained clears both marks at the start of a holder: a container started again is asked afresh.
|
||||||
|
func undrained() error {
|
||||||
|
for _, mark := range []string{drainMark(), inHandMark()} {
|
||||||
|
if err := os.Remove(mark); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("cannot remove %s: %w", mark, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// busy is the ask: it drains the agent, then says whether a build is in hand. Nil — exit 0 — while one is.
|
||||||
|
func busy() (string, error) {
|
||||||
|
// The mark keeps when the drain began while it is asked within drainLapse, and is written again on every
|
||||||
|
// ask: its time is the last ask.
|
||||||
|
began := time.Now().UTC()
|
||||||
|
if was, _, ok := drainedAt(); ok && drained() {
|
||||||
|
began = was.UTC()
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(drainMark(), []byte(began.Format(time.RFC3339)+"\n"), 0o644); err != nil {
|
||||||
|
// Not drained, it could take the next build: said, and answered busy only for what is in hand now.
|
||||||
|
fmt.Fprintf(os.Stderr, "cannot mark this agent drained (%s): %v\n", drainMark(), err)
|
||||||
|
}
|
||||||
|
held, err := os.ReadFile(inHandMark())
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("idle: no build in hand, and this agent takes no new one")
|
||||||
|
}
|
||||||
|
since, what, _ := strings.Cut(strings.TrimSpace(string(held)), " ")
|
||||||
|
if what == "" {
|
||||||
|
what = "a build"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("busy: %s, in hand since %s; taking no new build", what, since), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// `mesh-builder busy` answers 0 while a build is in hand and non-zero when idle, and after its first ask the
|
||||||
|
// agent takes no new build request (novox/hq ADR 0335 §5).
|
||||||
|
func TestBusyAnswersWhileABuildIsInHandAndDrainsTheAgent(t *testing.T) {
|
||||||
|
t.Setenv("TMPDIR", t.TempDir())
|
||||||
|
if err := undrained(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if drained() {
|
||||||
|
t.Fatal("an agent never asked is drained")
|
||||||
|
}
|
||||||
|
// A build is claimed and runs.
|
||||||
|
if !claim() {
|
||||||
|
t.Fatal("an agent never asked refused a build")
|
||||||
|
}
|
||||||
|
holding("novox/mesh-controller (check-280)")
|
||||||
|
said, err := busy()
|
||||||
|
if err != nil || !strings.Contains(said, "novox/mesh-controller (check-280)") {
|
||||||
|
t.Fatalf("an agent with a build in hand did not answer busy with what it holds: %q %v", said, err)
|
||||||
|
}
|
||||||
|
// From that ask on it takes nothing new: the fetch is not made, and one fetched is handed back.
|
||||||
|
if !drained() {
|
||||||
|
t.Fatal("the ask did not drain the agent")
|
||||||
|
}
|
||||||
|
if claim() {
|
||||||
|
t.Fatal("a drained agent took a new build")
|
||||||
|
}
|
||||||
|
// The build in hand is still in hand after the refused claim, and busy goes on saying so.
|
||||||
|
if _, err := busy(); err != nil {
|
||||||
|
t.Fatalf("a refused claim ended the build in hand: %v", err)
|
||||||
|
}
|
||||||
|
// It ends: idle, and still drained.
|
||||||
|
released()
|
||||||
|
if _, err := busy(); err == nil {
|
||||||
|
t.Fatal("an agent with nothing in hand answered busy")
|
||||||
|
}
|
||||||
|
if claim() {
|
||||||
|
t.Fatal("a drained agent took a build after its last one ended")
|
||||||
|
}
|
||||||
|
// Started again, it is asked afresh.
|
||||||
|
if err := undrained(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if drained() || !claim() {
|
||||||
|
t.Fatal("a holder started again is still drained")
|
||||||
|
}
|
||||||
|
released()
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ask racing a fetch sees the build in hand, or the agent sees the drain and hands the request back: never
|
||||||
|
// neither, which would replace the container under a build just taken.
|
||||||
|
func TestAnAskRacingAFetchNeverMissesTheBuild(t *testing.T) {
|
||||||
|
t.Setenv("TMPDIR", t.TempDir())
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
if err := undrained(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var claimed bool
|
||||||
|
var askErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() { defer wg.Done(); claimed = claim() }()
|
||||||
|
go func() { defer wg.Done(); _, askErr = busy() }()
|
||||||
|
wg.Wait()
|
||||||
|
if claimed && askErr != nil {
|
||||||
|
t.Fatalf("round %d: the agent took a build and the ask said idle", i)
|
||||||
|
}
|
||||||
|
released()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A drain no longer asked lapses: an agent whose replacement was withdrawn after the first ask (a put-back, a
|
||||||
|
// revert, a stopped walk) takes work again after drainLapse, rather than staying drained with its health green.
|
||||||
|
func TestADrainNoLongerAskedLapses(t *testing.T) {
|
||||||
|
t.Setenv("TMPDIR", t.TempDir())
|
||||||
|
if err := undrained(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := busy(); err == nil {
|
||||||
|
t.Fatal("an idle agent answered busy")
|
||||||
|
}
|
||||||
|
if !drained() || claim() {
|
||||||
|
t.Fatal("an agent just asked takes work")
|
||||||
|
}
|
||||||
|
// The last ask was three minutes ago.
|
||||||
|
old := time.Now().Add(-3 * time.Minute)
|
||||||
|
if err := os.Chtimes(drainMark(), old, old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if drained() {
|
||||||
|
t.Fatal("a drain no longer asked for three minutes still holds")
|
||||||
|
}
|
||||||
|
if !claim() {
|
||||||
|
t.Fatal("an agent whose drain lapsed takes no work")
|
||||||
|
}
|
||||||
|
released()
|
||||||
|
}
|
||||||
|
|
||||||
|
// An agent drained, and asked all along, for longer than its node-engine's bound and the slack is not ready: the
|
||||||
|
// replacement it was drained for never came. One asked again after a lapse starts a new drain.
|
||||||
|
func TestAnAgentDrainedTooLongIsNotReady(t *testing.T) {
|
||||||
|
t.Setenv("TMPDIR", t.TempDir())
|
||||||
|
if err := undrained(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
taking("mesh-build-machine")
|
||||||
|
if _, err := busy(); err == nil {
|
||||||
|
t.Fatal("an idle agent answered busy")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := drainedTooLong(now); err != nil {
|
||||||
|
t.Fatalf("an agent drained a moment ago is not ready: %v", err)
|
||||||
|
}
|
||||||
|
// The drain began 25 minutes ago, and the node-engine asked a moment ago.
|
||||||
|
began := now.Add(-25 * time.Minute).UTC().Format(time.RFC3339)
|
||||||
|
if err := os.WriteFile(drainMark(), []byte(began+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := drainedTooLong(time.Now())
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "has not come") {
|
||||||
|
t.Fatalf("an agent drained for 25 minutes reads ready: %v", err)
|
||||||
|
}
|
||||||
|
// Each ask keeps when the drain began.
|
||||||
|
if _, err := busy(); err == nil {
|
||||||
|
t.Fatal("an idle agent answered busy")
|
||||||
|
}
|
||||||
|
if err := drainedTooLong(time.Now()); err == nil {
|
||||||
|
t.Fatal("an ask started the drain again")
|
||||||
|
}
|
||||||
|
// The asks stopped: the drain lapsed, and so did the fault.
|
||||||
|
old := time.Now().Add(-3 * time.Minute)
|
||||||
|
if err := os.Chtimes(drainMark(), old, old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := drainedTooLong(time.Now()); err != nil {
|
||||||
|
t.Fatalf("a lapsed drain still reads not ready: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ask this build does not know exits non-zero: an older image asked `busy` reads idle, not busy for the
|
||||||
|
// node-engine's whole bound.
|
||||||
|
func TestAnUnknownCommandFails(t *testing.T) {
|
||||||
|
was := os.Args
|
||||||
|
t.Cleanup(func() { os.Args = was })
|
||||||
|
os.Args = []string{"mesh-builder", "some-later-word"}
|
||||||
|
if err := run(); err == nil {
|
||||||
|
t.Fatal("an unknown command exited 0")
|
||||||
|
}
|
||||||
|
os.Args = []string{"mesh-builder", "help"}
|
||||||
|
if err := run(); err != nil {
|
||||||
|
t.Fatalf("help failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ type holder struct {
|
|||||||
say func(link.HolderState) error
|
say func(link.HolderState) error
|
||||||
// remove runs what a kill needs outside the build: the containers left behind.
|
// remove runs what a kill needs outside the build: the containers left behind.
|
||||||
remove builder.Runner
|
remove builder.Runner
|
||||||
|
// scratch is where check builds are kept (novox/hq ADR 0350 §5): what the week's sweep lets go of. Nil where
|
||||||
|
// this machine publishes to no artifact store.
|
||||||
|
scratch builder.Scratch
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
paused bool
|
paused bool
|
||||||
@@ -131,6 +134,34 @@ func (h *holder) stateWhilePaused(ctx context.Context, every time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expireChecks lets go of every check build older than a week (novox/hq ADR 0350 §5), at start and every while
|
||||||
|
// after. Every holder sweeps the one store; what one let go of, another finds gone.
|
||||||
|
func (h *holder) expireChecks(ctx context.Context, every time.Duration, now func() time.Time) {
|
||||||
|
sweep := func() {
|
||||||
|
if h.scratch == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.scratch.Expire(ctx, builder.CheckBuildsKept, now())
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
fmt.Fprintf(os.Stderr, "check builds older than %s: %d let go of, and not all: %v\n", builder.CheckBuildsKept, n, err)
|
||||||
|
case n > 0:
|
||||||
|
fmt.Fprintf(os.Stderr, "check builds older than %s: %d check(s)' builds let go of\n", builder.CheckBuildsKept, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sweep()
|
||||||
|
tick := time.NewTicker(every)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
sweep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// begin records the build this machine took, with the cancel that ends it.
|
// begin records the build this machine took, with the cancel that ends it.
|
||||||
func (h *holder) begin(request link.BuildRequest, cancel context.CancelFunc) *running {
|
func (h *holder) begin(request link.BuildRequest, cancel context.CancelFunc) *running {
|
||||||
r := &running{request: request, started: time.Now(), cancel: cancel, done: make(chan struct{})}
|
r := &running{request: request, started: time.Now(), cancel: cancel, done: make(chan struct{})}
|
||||||
|
|||||||
+138
-8
@@ -75,6 +75,12 @@ digest of their own configuration. The result is printed as JSON.
|
|||||||
|
|
||||||
exits 0 once the agent running in this container is bound to its build work and pulls it, and
|
exits 0 once the agent running in this container is bound to its build work and pulls it, and
|
||||||
1 before: its container's health check.
|
1 before: its container's health check.
|
||||||
|
|
||||||
|
mesh-builder busy
|
||||||
|
|
||||||
|
exits 0 while the agent running in this container has a build in hand, and 1 when it has none;
|
||||||
|
from the first ask the agent takes no new build: its container's busy command, asked before the
|
||||||
|
container is replaced (novox/hq ADR 0335).
|
||||||
`
|
`
|
||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
@@ -86,17 +92,41 @@ func run() error {
|
|||||||
case "build":
|
case "build":
|
||||||
return buildOnce(context.Background(), os.Args[2:])
|
return buildOnce(context.Background(), os.Args[2:])
|
||||||
case "ready":
|
case "ready":
|
||||||
// The container's health check (novox/hq issue 544): exits non-zero until the agent takes work.
|
// The container's health check (novox/hq issue 544): exits non-zero until the agent takes work, and
|
||||||
return ready()
|
// once it has been drained longer than its node-engine would wait to replace it (ADR 0335).
|
||||||
default:
|
if err := ready(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return drainedTooLong(time.Now())
|
||||||
|
case "busy":
|
||||||
|
// The container's busy command (novox/hq ADR 0335): drains the agent, and exits non-zero once
|
||||||
|
// nothing is in hand.
|
||||||
|
said, err := busy()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(said)
|
||||||
|
return nil
|
||||||
|
case "help", "-h", "--help":
|
||||||
fmt.Print(usage)
|
fmt.Print(usage)
|
||||||
return nil
|
return nil
|
||||||
|
default:
|
||||||
|
// **An ask this build does not know fails** (novox/hq ADR 0335): an image older than `busy`, asked
|
||||||
|
// it, must read as idle — exiting 0 would hold its container for the node-engine's whole bound.
|
||||||
|
fmt.Fprint(os.Stderr, usage)
|
||||||
|
return fmt.Errorf("%q is not a command this mesh-builder knows", os.Args[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := notReady(); err != nil {
|
if err := notReady(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// **The builder's own version is part of every reuse key** (novox/hq ADR 0350 §1): a builder that builds
|
||||||
|
// differently never takes a build another one made.
|
||||||
|
builder.Version = builder.SelfVersion(version)
|
||||||
|
if err := undrained(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
credential, err := brokerFrom()
|
credential, err := brokerFrom()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -149,8 +179,13 @@ func run() error {
|
|||||||
// **What an earlier holder here left goes before anything is taken** (novox/hq issue 306): a holder
|
// **What an earlier holder here left goes before anything is taken** (novox/hq issue 306): a holder
|
||||||
// recreated mid-check left the check's store and bus running, and the ask went to another machine.
|
// recreated mid-check left the check's store and bus running, and the ask went to another machine.
|
||||||
h.removeLeftBehind()
|
h.removeLeftBehind()
|
||||||
machine := link.MachineOverNATSWith(js, on, seat, link.MachineOptions{Paused: h.Paused,
|
// Paused or drained, it takes nothing new (novox/hq ADR 0219, ADR 0335): a drained agent finishes the build
|
||||||
Taking: func() { taking(seat) }})
|
// in hand and then says idle, and its container is replaced between builds.
|
||||||
|
machine := link.MachineOverNATSWith(js, on, seat, link.MachineOptions{
|
||||||
|
Paused: func() bool { return h.Paused() || drained() },
|
||||||
|
Taking: func() { taking(seat) },
|
||||||
|
Claim: claim,
|
||||||
|
})
|
||||||
defer machine.Close()
|
defer machine.Close()
|
||||||
|
|
||||||
// The seat's verbs, on this machine's subjects. Only the seat that declares them: the retired
|
// The seat's verbs, on this machine's subjects. Only the seat that declares them: the retired
|
||||||
@@ -174,8 +209,13 @@ func run() error {
|
|||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "building for the mesh, publishing to %s\n", registry)
|
fmt.Fprintf(os.Stderr, "building for the mesh, publishing to %s\n", registry)
|
||||||
publisher := builder.Registry{Address: registry, Run: builder.Command}
|
publisher := builder.Registry{Address: registry, Run: builder.Command}
|
||||||
|
// Check builds are let go of after a week (novox/hq ADR 0350 §5).
|
||||||
|
h.scratch = publisher
|
||||||
|
go h.expireChecks(ctx, time.Hour, time.Now)
|
||||||
|
|
||||||
return machine.Take(ctx, func(ctx context.Context, work link.Build) {
|
return machine.Take(ctx, func(ctx context.Context, work link.Build) {
|
||||||
|
defer released()
|
||||||
|
holding(work.Request().Repository + " (" + work.Request().ID + ")")
|
||||||
answer(ctx, publisher, on, workspace, work, h)
|
answer(ctx, publisher, on, workspace, work, h)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -262,6 +302,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
say("packages", saysRegistry(npmrc))
|
say("packages", saysRegistry(npmrc))
|
||||||
}
|
}
|
||||||
|
registryHere, isRegistry := publisher.(builder.Registry)
|
||||||
var built builder.Result
|
var built builder.Result
|
||||||
if request.Check != nil {
|
if request.Check != nil {
|
||||||
// **A pull request's merge check, not a build** (novox/hq to-be 45 §9): nothing is built,
|
// **A pull request's merge check, not a build** (novox/hq to-be 45 §9): nothing is built,
|
||||||
@@ -271,17 +312,32 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
if r, ok := publisher.(builder.Registry); ok {
|
if r, ok := publisher.(builder.Registry); ok {
|
||||||
registry = r.Address
|
registry = r.Address
|
||||||
}
|
}
|
||||||
|
spec := checkSpecOf(request)
|
||||||
|
// What the merge would move, built by the check for the walk to take (novox/hq ADR 0350 §1): into the
|
||||||
|
// scratch namespace. **Never with the package registry's credential**: this is code nobody has approved
|
||||||
|
// yet, and a recipe could read the credential it is handed (issue 462). A module that resolves packages
|
||||||
|
// at build time has no reuse key anyway, so nothing is lost by refusing it here.
|
||||||
|
if isRegistry && len(spec.Builds) > 0 {
|
||||||
|
spec.BuildWith = &builder.CheckBuilding{Publish: publisher, Scratch: registryHere, Held: request.Held,
|
||||||
|
Seats: request.Seats}
|
||||||
|
}
|
||||||
var v builder.CheckVerdict
|
var v builder.CheckVerdict
|
||||||
v, err = builder.Check(building, builder.Command, checkSpecOf(request), workspace, registry, forgeFrom(), say)
|
v, err = builder.Check(building, builder.Command, spec, workspace, registry, forgeFrom(), say)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report,
|
result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report,
|
||||||
Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo),
|
Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo),
|
||||||
Head: v.Head, Trunk: v.Trunk, Beside: v.Beside}
|
Head: v.Head, Trunk: v.Trunk, Beside: v.Beside, Built: checkBuiltOf(v.Built)}
|
||||||
// What each of a group's heads was judged as (novox/hq ADR 0323 §6).
|
// What each of a group's heads was judged as (novox/hq ADR 0323 §6).
|
||||||
for _, m := range v.Members {
|
for _, m := range v.Members {
|
||||||
result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo,
|
result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo,
|
||||||
Commit: m.Head, Trunk: m.Trunk})
|
Commit: m.Head, Trunk: m.Trunk})
|
||||||
}
|
}
|
||||||
|
// A branch tried before its pull request reads its outcome from this log, whole (novox/hq issue 686).
|
||||||
|
if request.Check.Try {
|
||||||
|
if line, err := json.Marshal(triedOf(v)); err == nil {
|
||||||
|
say(link.StepTried, string(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if err == nil {
|
} else if err == nil {
|
||||||
// The package-registry credential is a build input, so it is resolved before the clone: a
|
// The package-registry credential is a build input, so it is resolved before the clone: a
|
||||||
@@ -289,10 +345,31 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
// after a clone that then fails at npm ci.
|
// after a clone that then fails at npm ci.
|
||||||
// Every container it starts is labelled with its id, so a kill finds what outlived the
|
// Every container it starts is labelled with its id, so a kill finds what outlived the
|
||||||
// docker client (ADR 0219).
|
// docker client (ADR 0219).
|
||||||
|
if request.Reuse != nil && isRegistry && !request.DryRun {
|
||||||
|
// **A walk's build takes the check's build of the same key** (novox/hq ADR 0350 §2).
|
||||||
|
reuse := builder.Reuse{Scratch: registryHere}
|
||||||
|
for _, p := range request.Reuse.Pulls {
|
||||||
|
reuse.Pulls = append(reuse.Pulls, builder.PullRef{Owner: p.Owner, Repo: p.Repo, Number: p.Number})
|
||||||
|
}
|
||||||
|
// Only what a verdict names is taken (ADR 0350 §2).
|
||||||
|
for _, b := range request.Reuse.Builds {
|
||||||
|
named := builder.NamedBuild{Pull: builder.PullRef{Owner: b.Pull.Owner, Repo: b.Pull.Repo,
|
||||||
|
Number: b.Pull.Number}, Check: b.Check, Module: b.Built.Module, Key: b.Built.Key,
|
||||||
|
Digests: map[string]string{}}
|
||||||
|
for _, m := range b.Built.Made {
|
||||||
|
named.Digests[m.Name] = builder.DigestOf(m.Reference)
|
||||||
|
}
|
||||||
|
reuse.Named = append(reuse.Named, named)
|
||||||
|
}
|
||||||
|
built, err = builder.BuildReusing(building, builder.Labelled(builder.Command, request.ID), publisher, reuse,
|
||||||
|
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
||||||
|
forgeFrom(), say, request.Seats)
|
||||||
|
} else {
|
||||||
built, err = builder.Build(building, builder.Labelled(builder.Command, request.ID), publisher,
|
built, err = builder.Build(building, builder.Labelled(builder.Command, request.ID), publisher,
|
||||||
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
||||||
forgeFrom(), say, request.Seats)
|
forgeFrom(), say, request.Seats)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// What it copied into the store, whatever became of the build (novox/hq ADR 0257).
|
// What it copied into the store, whatever became of the build (novox/hq ADR 0257).
|
||||||
result.Mirrored = built.Mirrored
|
result.Mirrored = built.Mirrored
|
||||||
// Only a build the kill ended: the kill came before its work did. One that finished — built, or
|
// Only a build the kill ended: the kill came before its work did. One that finished — built, or
|
||||||
@@ -327,6 +404,19 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
}
|
}
|
||||||
result.Against = built.Against
|
result.Against = built.Against
|
||||||
result.SourceFingerprint = built.Source
|
result.SourceFingerprint = built.Source
|
||||||
|
// What the walk did instead of building, or why it built (novox/hq ADR 0350 §3, §4).
|
||||||
|
result.ReuseKey = built.ReuseKey
|
||||||
|
if built.Reused != nil {
|
||||||
|
f := built.Reused.From
|
||||||
|
result.Reused = built.Reused.Words()
|
||||||
|
result.ReusedFrom = &link.ReusedFrom{Pull: link.PullRef{Owner: f.Pull.Owner, Repo: f.Pull.Repo,
|
||||||
|
Number: f.Pull.Number}, Head: f.Head, Trunk: f.Trunk, Check: f.Check, Key: f.Key}
|
||||||
|
} else if request.Reuse != nil {
|
||||||
|
result.BuiltWhy = built.Why
|
||||||
|
if result.BuiltWhy == "" {
|
||||||
|
result.BuiltWhy = "it has no artifact to build"
|
||||||
|
}
|
||||||
|
}
|
||||||
result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches
|
result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches
|
||||||
for _, r := range built.Read {
|
for _, r := range built.Read {
|
||||||
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
@@ -334,6 +424,11 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
for _, s := range built.Sources {
|
for _, s := range built.Sources {
|
||||||
result.Sources = append(result.Sources, link.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
|
result.Sources = append(result.Sources, link.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
|
||||||
}
|
}
|
||||||
|
// The list of predefined keys the manifest was judged against, for the controller to publish
|
||||||
|
// (novox/hq ADR 0348 §7).
|
||||||
|
if p := built.Predefined; p != nil {
|
||||||
|
result.Predefined = &link.PredefinedList{Keys: p.Keys, Generation: p.Generation}
|
||||||
|
}
|
||||||
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,10 +468,15 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
|
|||||||
spec := builder.CheckSpec{ID: request.ID, Repository: request.Repository, Ref: request.Ref,
|
spec := builder.CheckSpec{ID: request.ID, Repository: request.Repository, Ref: request.Ref,
|
||||||
Owner: c.Owner, Repo: c.Repo, Number: c.Number, Paths: c.Paths, Beside: map[string]builder.Beside{},
|
Owner: c.Owner, Repo: c.Repo, Number: c.Number, Paths: c.Paths, Beside: map[string]builder.Beside{},
|
||||||
Modules: c.Modules, New: c.New, Manifests: c.Manifests, Judge: c.Judge, Base: c.Base,
|
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}
|
||||||
for dir, b := range c.Beside {
|
for dir, b := range c.Beside {
|
||||||
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref}
|
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref}
|
||||||
}
|
}
|
||||||
|
for _, b := range c.Builds {
|
||||||
|
spec.Builds = append(spec.Builds, builder.CheckBuildSpec{Module: b.Module, Repository: b.Repository, Path: b.Path,
|
||||||
|
Ref: b.Ref, Merged: b.Merged, Tier: b.Tier, Skip: b.Skip})
|
||||||
|
}
|
||||||
for _, m := range c.Members {
|
for _, m := range c.Members {
|
||||||
spec.Group = append(spec.Group, builder.GroupHead{Owner: m.Owner, Repo: m.Repo, Repository: m.Repository,
|
spec.Group = append(spec.Group, builder.GroupHead{Owner: m.Owner, Repo: m.Repo, Repository: m.Repository,
|
||||||
Ref: m.Ref, Paths: m.Paths, Base: m.Base})
|
Ref: m.Ref, Paths: m.Paths, Base: m.Base})
|
||||||
@@ -384,6 +484,36 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
|
|||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// triedOf is a try's outcome as its log line says it (novox/hq issue 686): the repository's own check's verdict,
|
||||||
|
// or the check's where that did not run — a head that does not merge — and the lines naming what failed.
|
||||||
|
func triedOf(v builder.CheckVerdict) link.Tried {
|
||||||
|
t := link.Tried{Verdict: v.Verdict, Summary: v.Summary, Took: v.Took.Round(time.Second).String(),
|
||||||
|
Head: v.Head, Trunk: v.Trunk}
|
||||||
|
if v.Repo != nil && (v.Gate == nil || v.Gate.Verdict == "pass") {
|
||||||
|
t.Verdict, t.Summary = v.Repo.Verdict, v.Repo.Summary
|
||||||
|
}
|
||||||
|
if v.Repo != nil && v.Repo.Failed != "" {
|
||||||
|
t.Failed = strings.Split(v.Repo.Failed, "\n")
|
||||||
|
}
|
||||||
|
if t.Verdict == "" {
|
||||||
|
t.Verdict = "error"
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBuiltOf is a check's builds for its merge as the outcome carries them (novox/hq ADR 0350 §1).
|
||||||
|
func checkBuiltOf(built []builder.CheckBuilt) []link.CheckBuilt {
|
||||||
|
var out []link.CheckBuilt
|
||||||
|
for _, b := range built {
|
||||||
|
c := link.CheckBuilt{Module: b.Module, Key: b.Key, NotBuilt: b.NotBuilt, Took: b.Took.Round(time.Second).String()}
|
||||||
|
for _, m := range b.Made {
|
||||||
|
c.Made = append(c.Made, link.MadeArtifact{Name: m.Name, Kind: m.Kind, Reference: m.Reference})
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// layerOf is one layer of a check as the outcome carries it.
|
// layerOf is one layer of a check as the outcome carries it.
|
||||||
func layerOf(l *builder.Layer) *link.CheckLayer {
|
func layerOf(l *builder.Layer) *link.CheckLayer {
|
||||||
if l == nil {
|
if l == nil {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func TestBuilderDiagnosticsStayOffStdout(t *testing.T) {
|
|||||||
"version)": true, // --version
|
"version)": true, // --version
|
||||||
`"stopping")`: true, // the loop.s shutdown line
|
`"stopping")`: true, // the loop.s shutdown line
|
||||||
"usage)": true, // --help text, for a human
|
"usage)": true, // --help text, for a human
|
||||||
|
"said)": true, // busy: the answer its node-engine reads (novox/hq ADR 0335)
|
||||||
}
|
}
|
||||||
for _, file := range []string{"once.go", "main.go"} {
|
for _, file := range []string{"once.go", "main.go"} {
|
||||||
src, err := os.ReadFile(file)
|
src, err := os.ReadFile(file)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/builder"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A try's outcome, as the build seat says it in the build's log (novox/hq issue 686): the repository's own
|
||||||
|
// check's verdict, how long it took, and each line naming what failed.
|
||||||
|
func TestATrysOutcomeNamesWhatFailed(t *testing.T) {
|
||||||
|
tried := triedOf(builder.CheckVerdict{Verdict: "pass", Summary: "the change touches no module", Took: 303 * time.Second,
|
||||||
|
Head: "c0ffee", Trunk: "ca3f43c", Gate: &builder.Layer{Verdict: "pass"},
|
||||||
|
Repo: &builder.Layer{Verdict: "fail", Summary: "its merge-check.sh failed: TestX",
|
||||||
|
Failed: "--- FAIL: TestX (0.01s)\n x_test.go:12: got 1, want 2"}})
|
||||||
|
if tried.Verdict != "fail" || tried.Took != "5m3s" || len(tried.Failed) != 2 || tried.Trunk != "ca3f43c" ||
|
||||||
|
tried.Summary != "its merge-check.sh failed: TestX" {
|
||||||
|
t.Fatalf("a failed try: %+v", tried)
|
||||||
|
}
|
||||||
|
// A head that does not merge fails both layers: the check's own word.
|
||||||
|
conflict := triedOf(builder.CheckVerdict{Verdict: "fail", Summary: "does not merge", Gate: &builder.Layer{Verdict: "fail"},
|
||||||
|
Repo: &builder.Layer{Verdict: "fail", Summary: "does not merge"}})
|
||||||
|
if conflict.Verdict != "fail" || conflict.Summary != "does not merge" {
|
||||||
|
t.Fatalf("a try that does not merge: %+v", conflict)
|
||||||
|
}
|
||||||
|
// No verdict is an error, never a pass.
|
||||||
|
if none := triedOf(builder.CheckVerdict{}); none.Verdict != "error" {
|
||||||
|
t.Fatalf("a try with no verdict: %+v", none)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A provision whose credential only a person outside the mesh can give (novox/hq ADR 0337, issue 164, issue 165):
|
||||||
|
// given once for its provider, at the controller's terminal, and sealed in one act to every consumer that binds the
|
||||||
|
// provision from that provider now. The value is never read back, printed or kept.
|
||||||
|
|
||||||
|
// outsideOffer is the provider module's own name when `secret accept <node> <module> <name>` names a provision that
|
||||||
|
// module offers with a credential issued outside the mesh, and "" when it names anything else (an own secret, or a
|
||||||
|
// pair with --provider).
|
||||||
|
func outsideOffer(ctx context.Context, module, name, provider string) (string, error) {
|
||||||
|
if provider != "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if m, known := shelf[module]; known && m.IssuedOutsideOf(name) {
|
||||||
|
return module, nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// offeredOutsideBy is a module that offers the provision with a credential issued outside the mesh, or "".
|
||||||
|
func offeredOutsideBy(ctx context.Context, provision string) (string, error) {
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
module, _ := catalogue.OffersIssuedOutside(shelf, provision)
|
||||||
|
return module, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumersFrom is every consumer that binds a provision from one provider now, found by resolving every machine as
|
||||||
|
// its next send would, and the machines that could not be resolved, with why.
|
||||||
|
func consumersFrom(ctx context.Context, open *stores, provider, providerModule, provision string) (
|
||||||
|
[]inventory.PairEnd, []string, error) {
|
||||||
|
nodes, err := open.inventory.Nodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var out []inventory.PairEnd
|
||||||
|
var unresolved []string
|
||||||
|
for _, n := range nodes {
|
||||||
|
plan, _, err := planFor(ctx, open, n.Name)
|
||||||
|
if err != nil {
|
||||||
|
unresolved = append(unresolved, fmt.Sprintf("%s: %s", n.Name, oneLine(err.Error())))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, bindsFrom(plan, n.Name, provider, providerModule, provision)...)
|
||||||
|
}
|
||||||
|
return out, unresolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindsFrom is every consumer on one machine whose resolution answers the provision from that provider. Pure.
|
||||||
|
func bindsFrom(plan catalogue.Resolution, machine, provider, providerModule, provision string) []inventory.PairEnd {
|
||||||
|
var out []inventory.PairEnd
|
||||||
|
for _, need := range plan.Needs {
|
||||||
|
if need.ByRecord || need.Name != provision || need.From != provider {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if need.Module != "" && need.Module != providerModule {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, inventory.PairEnd{Consumer: machine, ConsumerModule: need.For, Local: need.Local})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return out[i].ConsumerModule+"\x00"+out[i].Local < out[j].ConsumerModule+"\x00"+out[j].Local
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptForProvision gives a provision's credential issued outside the mesh, once for its provider (ADR 0337 §3).
|
||||||
|
func acceptForProvision(ctx context.Context, provider, providerModule, provision, value string) error {
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
consumers, unresolved, err := consumersFrom(ctx, open, provider, providerModule, provision)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accepted, err := open.inventory.AcceptSecretForProvision(ctx, provision, provider, providerModule, value, consumers)
|
||||||
|
value = ""
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Print(acceptedSaid(provider, providerModule, provision, accepted, unresolved))
|
||||||
|
if len(accepted.Sealed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := sendTo(ctx, open, accepted.Machines); err != nil {
|
||||||
|
return fmt.Errorf("%s is sealed and could not be sent: %w; `push %s` sends it", provision, err,
|
||||||
|
strings.Join(accepted.Machines, "`, `push "))
|
||||||
|
}
|
||||||
|
fmt.Printf(" sent %s; each consumer starts again on it\n", strings.Join(accepted.Machines, ", "))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptedSaid is what one accept for a provider did, in words that never carry the value. Pure.
|
||||||
|
func acceptedSaid(provider, providerModule, provision string, a inventory.ProvisionAccepted, unresolved []string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
if len(a.Sealed) == 0 {
|
||||||
|
fmt.Fprintf(&b, "%s from %s on %s: no consumer binds it now, so nothing was sealed; a consumer that binds "+
|
||||||
|
"later waits for it, and this line given again seals it to every consumer then\n", provision,
|
||||||
|
providerModule, provider)
|
||||||
|
} else {
|
||||||
|
var names []string
|
||||||
|
for _, c := range a.Sealed {
|
||||||
|
names = append(names, fmt.Sprintf("%s on %s%s", c.ConsumerModule, c.Consumer, asLocal(c.Local)))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s from %s on %s is given, sealed to %s and to %s\n", provision, providerModule, provider,
|
||||||
|
strings.Join(names, ", "), provider)
|
||||||
|
fmt.Fprintf(&b, " the mesh cannot read it back, never makes one of its own for it, and will not rotate it\n")
|
||||||
|
}
|
||||||
|
for _, c := range a.Unsealable {
|
||||||
|
fmt.Fprintf(&b, " not sealed to %s on %s: no sealing key on that machine; give it again once it has one\n",
|
||||||
|
c.ConsumerModule, c.Consumer)
|
||||||
|
}
|
||||||
|
for _, u := range unresolved {
|
||||||
|
fmt.Fprintf(&b, " not known whether a consumer on %s binds it, which did not resolve; give it again once it does\n", u)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -203,8 +203,9 @@ func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, mo
|
|||||||
if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil {
|
if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
// Two modules declaring one package, path or unit is refused before anything is recorded
|
// Two modules declaring one path or unit, or one package present and absent, is refused before anything
|
||||||
// (novox/hq ADR 0210, 04-ISSUES/235): kept, the node would not resolve until one came off again.
|
// is recorded (novox/hq ADR 0210, ADR 0303, 04-ISSUES/235): kept, the node would not resolve until one came
|
||||||
|
// off again. Two modules declaring one package present is not a collision (ADR 0303, issue 628).
|
||||||
if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil {
|
if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,8 +424,9 @@ func comparisonOf(module string, c comparison, opts takeOptions) (preview string
|
|||||||
}
|
}
|
||||||
// And its settings on this machine, composed against its definition (rule 1, rule 6).
|
// And its settings on this machine, composed against its definition (rule 1, rule 6).
|
||||||
for _, layer := range c.layers {
|
for _, layer := range c.layers {
|
||||||
keys := make([]string, 0, len(layer.Values))
|
said := layer.Whole()
|
||||||
for k := range layer.Values {
|
keys := make([]string, 0, len(said))
|
||||||
|
for k := range said {
|
||||||
keys = append(keys, k)
|
keys = append(keys, k)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
@@ -722,7 +723,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
||||||
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks, local: with.LocalNetworks}
|
||||||
filtering, err := inv.FilteringOf(ctx, node)
|
filtering, err := inv.FilteringOf(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -930,6 +931,9 @@ type derivedFilter struct {
|
|||||||
// The filter constrains what arrives on them; everything arriving elsewhere is this machine's
|
// The filter constrains what arrives on them; everything arriving elsewhere is this machine's
|
||||||
// own guest and is not filtered.
|
// own guest and is not filtered.
|
||||||
outwardLinks []string
|
outwardLinks []string
|
||||||
|
// local is the machine's local networks, which a port from `local-network` stays open to (novox/hq
|
||||||
|
// ADR 0340).
|
||||||
|
local []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// closesOutside is what a narrowing from everywhere to the private network is called: it closes.
|
// closesOutside is what a narrowing from everywhere to the private network is called: it closes.
|
||||||
@@ -977,6 +981,17 @@ func (d derivedFilter) fate(r inventory.Reach) string {
|
|||||||
if !onMesh {
|
if !onMesh {
|
||||||
return fmt.Sprintf("%s — declared by %s from the mesh only", closesOutside, by)
|
return fmt.Sprintf("%s — declared by %s from the mesh only", closesOutside, by)
|
||||||
}
|
}
|
||||||
|
case catalogue.FromLocalNetwork:
|
||||||
|
// The private network and this machine's local networks (novox/hq ADR 0340): it closes to
|
||||||
|
// everything else outside, and says which local networks stay.
|
||||||
|
if !onMesh {
|
||||||
|
if len(d.local) == 0 {
|
||||||
|
return fmt.Sprintf("%s — declared by %s from the local network, and this machine "+
|
||||||
|
"reported none", closesOutside, by)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("WILL CLOSE to everything outside the private network and %s — declared "+
|
||||||
|
"by %s from the local network", strings.Join(d.local, ", "), by)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("declared by %s (from %s)", by, rule.From)
|
return fmt.Sprintf("declared by %s (from %s)", by, rule.From)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/builder"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
@@ -182,10 +183,12 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
|||||||
kept.Manifest = recordedManifest(result.Manifest, announced)
|
kept.Manifest = recordedManifest(result.Manifest, announced)
|
||||||
// The module name comes from the manifest, which only exists when the build got that far.
|
// The module name comes from the manifest, which only exists when the build got that far.
|
||||||
if len(kept.Manifest) > 0 {
|
if len(kept.Manifest) > 0 {
|
||||||
if m, err := catalogue.ParseManifest(kept.Manifest); err == nil {
|
if list, err := judgedWith(result); err == nil {
|
||||||
|
if m, err := catalogue.ParseManifestWith(kept.Manifest, list); err == nil {
|
||||||
kept.Module = m.Module
|
kept.Module = m.Module
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +486,7 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
|
|||||||
Seats: seatBases(ctx),
|
Seats: seatBases(ctx),
|
||||||
DryRun: dryRun,
|
DryRun: dryRun,
|
||||||
}
|
}
|
||||||
|
request.Reuse = reuseAsked(ctx, dryRun)
|
||||||
fmt.Printf("asked for %s", source)
|
fmt.Printf("asked for %s", source)
|
||||||
if source.Seat != "" {
|
if source.Seat != "" {
|
||||||
fmt.Printf(" (%s)", repository)
|
fmt.Printf(" (%s)", repository)
|
||||||
@@ -601,6 +605,11 @@ func saysWhenThePolicyActs(ctx context.Context, inv *inventory.Inventory, module
|
|||||||
// written with the same values.
|
// written with the same values.
|
||||||
func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) (
|
func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) (
|
||||||
catalogue.Manifest, inventory.Build, error) {
|
catalogue.Manifest, inventory.Build, error) {
|
||||||
|
// The list of predefined keys as the store keeps it now: another controller process may have kept a newer one.
|
||||||
|
if err := loadPredefined(ctx, inv); err != nil {
|
||||||
|
return catalogue.Manifest{}, inventory.Build{}, fmt.Errorf("the list of predefined keys kept cannot be "+
|
||||||
|
"read: %w", err)
|
||||||
|
}
|
||||||
kept := buildFrom(result)
|
kept := buildFrom(result)
|
||||||
if err := inv.RecordBuild(ctx, kept); err != nil {
|
if err := inv.RecordBuild(ctx, kept); err != nil {
|
||||||
return catalogue.Manifest{}, kept, err
|
return catalogue.Manifest{}, kept, err
|
||||||
@@ -611,7 +620,15 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s",
|
return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s",
|
||||||
result.On, result.Repository, result.Failed)
|
result.On, result.Repository, result.Failed)
|
||||||
}
|
}
|
||||||
manifest, err := catalogue.ParseManifest(kept.Manifest)
|
// Judged first against the list the build was judged against: the catalogue's at the commit built (novox/hq
|
||||||
|
// ADR 0348 §7), so a module naming a key its own commit adds is read; and again below against the list the
|
||||||
|
// mesh publishes, before it is registered.
|
||||||
|
list, err := judgedWith(result)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
|
||||||
|
result.Repository, short(result.Commit), err)
|
||||||
|
}
|
||||||
|
manifest, err := catalogue.ParseManifestWith(kept.Manifest, list)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
||||||
result.On, result.Repository, err)
|
result.On, result.Repository, err)
|
||||||
@@ -669,6 +686,12 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
|
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
|
||||||
manifest.Module, short(result.Commit), err)
|
manifest.Module, short(result.Commit), err)
|
||||||
}
|
}
|
||||||
|
// **Never a build in the scratch namespace** (novox/hq ADR 0239 §9, ADR 0350 §2): what a check builds is
|
||||||
|
// registered only as the walk's copy in the module's own namespace.
|
||||||
|
if scratch := inScratch(result.Made); scratch != "" {
|
||||||
|
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %s is in the scratch "+
|
||||||
|
"namespace, which only a walk's copy leaves (ADR 0350)", result.On, manifest.Module, short(result.Commit), scratch)
|
||||||
|
}
|
||||||
// **A build that failed its gate is never registered again** (novox/hq ADR 0236): an outcome heard
|
// **A build that failed its gate is never registered again** (novox/hq ADR 0236): an outcome heard
|
||||||
// twice, or replayed, would otherwise make the build a rollback put back what the module is again,
|
// twice, or replayed, would otherwise make the build a rollback put back what the module is again,
|
||||||
// and the next push would send it.
|
// and the next push would send it.
|
||||||
@@ -679,6 +702,23 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
"back: it is recorded and not registered again — a newer build is", result.On, manifest.Module,
|
"back: it is recorded and not registered again — a newer build is", result.On, manifest.Module,
|
||||||
short(result.Commit))
|
short(result.Commit))
|
||||||
}
|
}
|
||||||
|
// **The newest trunk build's list of predefined keys is published** (novox/hq ADR 0348 §7): a build of the
|
||||||
|
// catalogue that may be registered keeps its list when its commit is newer than the one kept. Then the module
|
||||||
|
// is judged against the list the mesh publishes, as every manifest read again is: one naming a key that list
|
||||||
|
// does not hold, or of another type, is refused here and never enters the catalogue.
|
||||||
|
if carried, err := carriedList(result); err != nil {
|
||||||
|
return manifest, kept, err
|
||||||
|
} else if carried != nil {
|
||||||
|
if err := publishPredefined(ctx, inv, result, carried); err != nil {
|
||||||
|
fmt.Printf("%s: the list of predefined keys it carried is not published: %v\n", result.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if manifest.NamesPredefined() {
|
||||||
|
if _, err := catalogue.ParseManifest(kept.Manifest); err != nil {
|
||||||
|
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: judged against the "+
|
||||||
|
"list of predefined keys the mesh publishes, %w", result.On, manifest.Module, short(result.Commit), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
// **A build whose source is unchanged is never a move** (novox/hq issue 280): a rebuild made from
|
// **A build whose source is unchanged is never a move** (novox/hq issue 280): a rebuild made from
|
||||||
// what the build the mesh stands on was made from registers that build's artifacts at the new
|
// what the build the mesh stands on was made from registers that build's artifacts at the new
|
||||||
// commit, so no machine is sent a new digest for a source nobody changed — an image is not
|
// commit, so no machine is sent a new digest for a source nobody changed — an image is not
|
||||||
@@ -714,6 +754,16 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
return manifest, kept, nil
|
return manifest, kept, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inScratch is the first artifact a build made into the scratch namespace; empty when none is.
|
||||||
|
func inScratch(made []link.MadeArtifact) string {
|
||||||
|
for _, m := range made {
|
||||||
|
if path, kept := catalogue.InArtifactStore(catalogue.Recorded(m.Reference)); kept && strings.HasPrefix(path, builder.ScratchPrefix) {
|
||||||
|
return m.Reference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// errOffTheTrunk is a build of a commit off its repository's trunk, which is never published.
|
// errOffTheTrunk is a build of a commit off its repository's trunk, which is never published.
|
||||||
var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " +
|
var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " +
|
||||||
"never published (ADR 0238) — merge it, and the merge builds it")
|
"never published (ADR 0238) — merge it, and the merge builds it")
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A first-node gate waits for a busy container (novox/hq ADR 0335 §4).
|
||||||
|
//
|
||||||
|
// When a machine reports, on what the gate sent it, that a container of a judged module was left running
|
||||||
|
// because it said busy, the judging has not started: the build is not yet what runs there. The walk says it
|
||||||
|
// waits for a busy container on that machine, counts no pass and no bound, and starts the judging — its
|
||||||
|
// Since — at the report that no longer lists the container waiting. The wait is the node-engine's, bounded
|
||||||
|
// at 15 minutes; the controller's own bound (busyGateBound past the engine's) is for an engine that stopped
|
||||||
|
// reporting while it waited, so no walk waits for ever. An engine older than the field never lists one, and
|
||||||
|
// its gate starts as before.
|
||||||
|
|
||||||
|
// busyGateSlack is how long past the engine's bound the controller still waits for the report that says the
|
||||||
|
// container replaced: a pass of the engine (its asks every 20 seconds) and the report's way here.
|
||||||
|
var busyGateSlack = 5 * time.Minute
|
||||||
|
|
||||||
|
// busyEngineBound is the node-engine's bound on a wait (novox/hq ADR 0335 §3), counted again here on this
|
||||||
|
// controller's clock from when it first read the wait.
|
||||||
|
const busyEngineBound = 15 * time.Minute
|
||||||
|
|
||||||
|
// busyGateEnds is when the gate stops waiting: the engine's own word on its bound, or this controller's count of
|
||||||
|
// it from when it first read the wait, whichever is sooner, and the slack — so neither a clock ahead on the
|
||||||
|
// machine nor a wrong word from it holds a walk longer.
|
||||||
|
func busyGateEnds(b *inventory.GateBusy) time.Time {
|
||||||
|
until := b.Seen.Add(busyEngineBound)
|
||||||
|
if !b.Until.IsZero() && b.Until.Before(until) {
|
||||||
|
until = b.Until
|
||||||
|
}
|
||||||
|
return until.Add(busyGateSlack)
|
||||||
|
}
|
||||||
|
|
||||||
|
// busyWait decides, before a judging, whether the gate waits for a busy container on a machine it judges:
|
||||||
|
// true while it waits, the gate saying so. False when nothing waits, or no longer: a wait that ended moves the
|
||||||
|
// gate's Since to its end. Pure but for the gate it is handed.
|
||||||
|
func busyWait(g *inventory.PlanGate, pairs []judged, f gateFacts, now time.Time) bool {
|
||||||
|
if g.Busy != nil && g.Busy.Ended != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, j := range pairs {
|
||||||
|
b, waits := busyOn(j, f)
|
||||||
|
if !waits {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if g.Busy == nil {
|
||||||
|
g.Busy = &inventory.GateBusy{Machine: j.node, Module: j.module, Container: b.Container, Since: b.Since,
|
||||||
|
Seen: now}
|
||||||
|
}
|
||||||
|
if g.Busy.Seen.IsZero() {
|
||||||
|
g.Busy.Seen = now
|
||||||
|
}
|
||||||
|
g.Busy.Until, g.Busy.Said = b.Until, b.Said
|
||||||
|
if now.After(busyGateEnds(g.Busy)) {
|
||||||
|
// The engine said it would have replaced it by now: the judging starts, and finds the module not
|
||||||
|
// yet healthy while the machine still lists it waiting (busyStill), until the gate's own bound.
|
||||||
|
end := now
|
||||||
|
g.Busy.Ended, g.Busy.PastBound = &end, true
|
||||||
|
g.Since, g.Passes, g.LastPass, g.Healthy, g.HealthyAt, g.Applied = &end, 0, nil, nil, nil, nil
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, g.Busy.Words(), nil
|
||||||
|
g.Read(now, false, g.Busy.Words())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if g.Busy == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Replaced: the judging starts at the report that said so.
|
||||||
|
end := now
|
||||||
|
if r, said := f.reports[g.Busy.Machine]; said && r.At != nil && r.At.After(g.Busy.Since) && r.At.Before(now) {
|
||||||
|
end = r.At.UTC()
|
||||||
|
}
|
||||||
|
g.Busy.Ended = &end
|
||||||
|
g.Since, g.Passes, g.LastPass, g.Last, g.Healthy, g.HealthyAt, g.Applied = &end, 0, nil, "", nil, nil, nil
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// busyOn is the container of a judged module its machine last reported, on what the gate sent it, as left
|
||||||
|
// running because it said busy.
|
||||||
|
func busyOn(j judged, f gateFacts) (inventory.BusyWait, bool) {
|
||||||
|
r, said := f.reports[j.node]
|
||||||
|
if !said || r.At == nil || !f.reportedOn(j.node, r) {
|
||||||
|
return inventory.BusyWait{}, false
|
||||||
|
}
|
||||||
|
return inventory.WaitingFor(r.Busy, j.module)
|
||||||
|
}
|
||||||
|
|
||||||
|
// busyStill is a judged module whose container its machine still lists waiting once the gate stopped waiting
|
||||||
|
// for it: not yet what was sent, so not healthy, whatever its health says of the container still running.
|
||||||
|
func busyStill(j judged, f gateFacts) (string, bool) {
|
||||||
|
b, waits := busyOn(j, f)
|
||||||
|
if !waits {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s still runs its busy container %s, past its node-engine's bound of %s",
|
||||||
|
j.node, b.Container, b.Until.UTC().Format(time.RFC3339)), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A walk whose module's container waits as busy on its first machine says so, and starts that machine's
|
||||||
|
// first-machine gate only once the machine reports the container replaced (novox/hq ADR 0335 §4).
|
||||||
|
func TestAGateWaitsForABusyContainerAndStartsWhenItIsReplaced(t *testing.T) {
|
||||||
|
sent := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
|
||||||
|
g := &inventory.PlanGate{Machines: []string{"novox"}, Since: &sent,
|
||||||
|
Sent: map[string]inventory.SentDeclaration{"novox": {Digest: "d1", Sequence: 7}}}
|
||||||
|
pairs := []judged{{module: "build-agent", node: "novox"}, {module: "mesh-controller", node: "novox"}}
|
||||||
|
reported := sent.Add(20 * time.Second)
|
||||||
|
waiting := []inventory.BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent",
|
||||||
|
State: inventory.BusyWaiting, Since: reported, Until: reported.Add(15 * time.Minute),
|
||||||
|
Said: "busy: novox/mesh-controller (check of #280), in hand since 00:58:00Z"}}
|
||||||
|
facts := func(at time.Time, busy []inventory.BusyWait) gateFacts {
|
||||||
|
// As judgeMoves hands it: the report held against the gate's own send.
|
||||||
|
return gateFacts{sent: g.Sent, reports: map[string]inventory.Reported{"novox": {Node: "novox",
|
||||||
|
Outcome: inventory.OutcomeApplied, At: &at, Declared: "d1", ReportedSequence: 7, Busy: busy}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := reported.Add(time.Minute)
|
||||||
|
if !busyWait(g, pairs, facts(reported, waiting), now) {
|
||||||
|
t.Fatal("a gate whose module's container waits as busy judged it")
|
||||||
|
}
|
||||||
|
if !g.Since.Equal(sent) || g.Busy == nil || g.Busy.Machine != "novox" || g.Busy.Ended != nil {
|
||||||
|
t.Fatalf("the gate does not keep the wait: %+v %+v", g.Since, g.Busy)
|
||||||
|
}
|
||||||
|
for _, said := range []string{gateLine(g), g.Last} {
|
||||||
|
if !strings.Contains(said, "waits for a busy container on novox") {
|
||||||
|
t.Errorf("the walk does not say it waits for a busy container there: %s", said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(g.Last, "check of #280") {
|
||||||
|
t.Errorf("the wait does not say what the container holds: %s", g.Last)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ten minutes on, still busy, within the engine's bound: still waiting, nothing judged, no bound run.
|
||||||
|
now = reported.Add(10 * time.Minute)
|
||||||
|
if !busyWait(g, pairs, facts(reported.Add(9*time.Minute), waiting), now) || g.Passes != 0 {
|
||||||
|
t.Fatalf("the gate stopped waiting within the bound: %+v", g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The machine reports it replaced: the judging starts at that report.
|
||||||
|
replacedAt := reported.Add(12 * time.Minute)
|
||||||
|
now = replacedAt.Add(5 * time.Second)
|
||||||
|
replaced := []inventory.BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent",
|
||||||
|
State: "replaced-when-idle", Since: reported, Until: reported.Add(15 * time.Minute)}}
|
||||||
|
if busyWait(g, pairs, facts(replacedAt, replaced), now) {
|
||||||
|
t.Fatal("the gate still waits after the container was replaced")
|
||||||
|
}
|
||||||
|
if g.Since == nil || !g.Since.Equal(replacedAt) || g.Busy.Ended == nil || !g.Busy.Ended.Equal(replacedAt) {
|
||||||
|
t.Fatalf("the gate does not start at the report that said it replaced: since %v, %+v", g.Since, g.Busy)
|
||||||
|
}
|
||||||
|
if strings.Contains(gateLine(g), "waits for a busy container") {
|
||||||
|
t.Errorf("a gate judging still says it waits: %s", gateLine(g))
|
||||||
|
}
|
||||||
|
// And a later report listing it waiting again does not hold a gate that started.
|
||||||
|
if busyWait(g, pairs, facts(now, waiting), now.Add(time.Minute)) {
|
||||||
|
t.Error("a gate that started waits again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An engine that stops reporting while it waits holds no walk for ever: past the engine's bound and the slack,
|
||||||
|
// the judging starts, and finds the module not yet healthy while its container is still listed waiting.
|
||||||
|
func TestAGateWaitsNoLongerThanTheEnginesBound(t *testing.T) {
|
||||||
|
sent := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
|
||||||
|
g := &inventory.PlanGate{Machines: []string{"novox"}, Since: &sent}
|
||||||
|
pairs := []judged{{module: "build-agent", node: "novox"}}
|
||||||
|
waiting := []inventory.BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent",
|
||||||
|
State: inventory.BusyWaiting, Since: sent, Until: sent.Add(15 * time.Minute)}}
|
||||||
|
f := gateFacts{reports: map[string]inventory.Reported{"novox": {Node: "novox", Outcome: inventory.OutcomeApplied,
|
||||||
|
At: &sent, Current: true, Busy: waiting}}}
|
||||||
|
now := sent.Add(15*time.Minute + busyGateSlack + time.Second)
|
||||||
|
if busyWait(g, pairs, f, now) {
|
||||||
|
t.Fatal("a gate waited past the engine's bound and the slack")
|
||||||
|
}
|
||||||
|
if !g.Busy.PastBound || !g.Since.Equal(now) {
|
||||||
|
t.Fatalf("the gate does not start, said past the bound: %+v %v", g.Busy, g.Since)
|
||||||
|
}
|
||||||
|
if why, still := busyStill(pairs[0], f); !still || !strings.Contains(why, "past its node-engine's bound") {
|
||||||
|
t.Fatalf("a module still running its busy container reads as what was sent: %q", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A report from an engine older than the field lists nothing: the gate starts at the send, as before; and a
|
||||||
|
// report not about the gate's send says nothing of it.
|
||||||
|
func TestAGateOfAnEngineThatNeverWaitsStartsAsBefore(t *testing.T) {
|
||||||
|
sent := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
|
||||||
|
g := &inventory.PlanGate{Machines: []string{"ace"}, Since: &sent,
|
||||||
|
Sent: map[string]inventory.SentDeclaration{"ace": {Digest: "d2", Sequence: 9}}}
|
||||||
|
pairs := []judged{{module: "build-agent", node: "ace"}}
|
||||||
|
at := sent.Add(time.Minute)
|
||||||
|
older := gateFacts{sent: g.Sent, reports: map[string]inventory.Reported{"ace": {Node: "ace", Outcome: inventory.OutcomeApplied,
|
||||||
|
At: &at, Declared: "d2", ReportedSequence: 9}}}
|
||||||
|
if busyWait(g, pairs, older, at) || g.Busy != nil || !g.Since.Equal(sent) {
|
||||||
|
t.Fatalf("a gate on an engine that never waits did not start at the send: %+v %v", g.Busy, g.Since)
|
||||||
|
}
|
||||||
|
elsewhere := gateFacts{sent: g.Sent, reports: map[string]inventory.Reported{"ace": {Node: "ace", Outcome: inventory.OutcomeApplied,
|
||||||
|
At: &at, Declared: "d1", ReportedSequence: 8, Busy: []inventory.BusyWait{{ID: "build-agent.server",
|
||||||
|
State: inventory.BusyWaiting, Until: at.Add(time.Hour)}}}}}
|
||||||
|
if busyWait(g, pairs, elsewhere, at) {
|
||||||
|
t.Fatal("a wait reported about an earlier declaration held the gate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wait is bounded on this controller's clock: an engine whose clock runs an hour ahead, saying it waits until
|
||||||
|
// then, holds the gate no longer than 15 minutes and the slack from when the controller first read the wait.
|
||||||
|
func TestAGateWaitIsBoundedOnTheControllersClock(t *testing.T) {
|
||||||
|
seen := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
|
||||||
|
g := &inventory.PlanGate{Machines: []string{"novox"}, Since: &seen}
|
||||||
|
pairs := []judged{{module: "build-agent", node: "novox"}}
|
||||||
|
ahead := seen.Add(time.Hour)
|
||||||
|
f := gateFacts{reports: map[string]inventory.Reported{"novox": {Node: "novox", Outcome: inventory.OutcomeApplied,
|
||||||
|
At: &seen, Current: true, Busy: []inventory.BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent",
|
||||||
|
State: inventory.BusyWaiting, Since: ahead, Until: ahead.Add(15 * time.Minute)}}}}}
|
||||||
|
if !busyWait(g, pairs, f, seen) || !g.Busy.Seen.Equal(seen) {
|
||||||
|
t.Fatalf("the gate did not start waiting, seen now: %+v", g.Busy)
|
||||||
|
}
|
||||||
|
if !busyWait(g, pairs, f, seen.Add(15*time.Minute+busyGateSlack-time.Second)) {
|
||||||
|
t.Fatal("the gate stopped waiting within the bound on its own clock")
|
||||||
|
}
|
||||||
|
if busyWait(g, pairs, f, seen.Add(15*time.Minute+busyGateSlack+time.Second)) || !g.Busy.PastBound {
|
||||||
|
t.Fatalf("an engine's later word held the gate past the controller's bound: %+v", g.Busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `plan` says, for a machine whose node-engine is older than the field, each container whose busy is not sent.
|
||||||
|
func TestAPlanSaysABusyAnOlderEngineIsNotSent(t *testing.T) {
|
||||||
|
modules := []catalogue.Manifest{{Module: "build-agent", Resources: []map[string]any{
|
||||||
|
{"id": "server", "type": "container", "busy": map[string]any{"command": "mesh-builder busy"}},
|
||||||
|
{"id": "agent-env", "type": "file"}}}}
|
||||||
|
lines := busyNotSent(modules, false)
|
||||||
|
if len(lines) != 1 || !strings.Contains(lines[0], "build-agent.server declares busy") ||
|
||||||
|
!strings.Contains(lines[0], "replaced at once") {
|
||||||
|
t.Fatalf("the plan does not say the busy not sent: %v", lines)
|
||||||
|
}
|
||||||
|
if lines := busyNotSent(modules, true); len(lines) != 0 {
|
||||||
|
t.Fatalf("a busy sent is said as not sent: %v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,24 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
|||||||
shelf := catalogue.Shelf{}
|
shelf := catalogue.Shelf{}
|
||||||
faulted := map[string]bool{}
|
faulted := map[string]bool{}
|
||||||
failed := 0
|
failed := 0
|
||||||
|
// The list of predefined keys at the root of the repository above each manifest (novox/hq ADR 0348 §7), read
|
||||||
|
// once per repository: a manifest naming a key is judged against the list its own tree holds.
|
||||||
|
lists := map[string]catalogue.PredefinedList{}
|
||||||
|
listOf := func(path string) (catalogue.PredefinedList, error) {
|
||||||
|
root := catalogue.RepositoryRoot(filepath.Dir(path))
|
||||||
|
if root == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if list, read := lists[root]; read {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
list, err := catalogue.ReadPredefined(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lists[root] = list
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,7 +72,18 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
|||||||
failed++
|
failed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m, err := catalogue.ParseManifest(raw)
|
list, err := listOf(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(out, "%s: %v\n", path, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, err := catalogue.ParseManifestWith(raw, list)
|
||||||
|
if err == nil {
|
||||||
|
// Each template's text read from the tree beside the manifest, and judged as inline content is
|
||||||
|
// (novox/hq ADR 0349 §2): a missing template is refused by name.
|
||||||
|
m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(path))
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(out, "%s: %v\n", path, err)
|
fmt.Fprintf(out, "%s: %v\n", path, err)
|
||||||
failed++
|
failed++
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/builder"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A pull request's check builds go when it closes (novox/hq ADR 0350 §5): the build seat keeps them in the artifact
|
||||||
|
// store's scratch namespace for the walk of its merge to take. **Let go of where the moment is heard**: the
|
||||||
|
// controller ends the walk answering a merge — after that walk took what it reuses — and the delivery's owner, which
|
||||||
|
// hears the forge say a pull request closed unmerged, asks the `forget-checks` verb; both let go through the store,
|
||||||
|
// as the controller's own collection does. The build seat's holders let go of anything a week old on their own,
|
||||||
|
// so a word lost here leaves a week's builds, never more.
|
||||||
|
|
||||||
|
// checkBuildsIn is the scratch namespace as the controller reaches the artifact store.
|
||||||
|
func checkBuildsIn(ctx context.Context, inv *inventory.Inventory) (builder.Scratch, error) {
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
address, err := artifactStoreAddress(ctx, inv, shelf, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if address == "" {
|
||||||
|
return nil, errors.New("the artifact store is not on the mesh's network")
|
||||||
|
}
|
||||||
|
return builder.Registry{Address: address}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetChecks lets go of a pull request's check builds and answers what it did.
|
||||||
|
var forgetChecks = func(ctx context.Context, inv *inventory.Inventory, pull builder.PullRef) (string, error) {
|
||||||
|
scratch, err := checkBuildsIn(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
n, err := scratch.Forget(ctx, pull)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s: %d check(s)' builds let go of, and not all: %w", pull, n, err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %d check(s)' builds let go of", pull, n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetChecksOfWalk lets go, on the save that ends a walk, of the check builds of every pull request whose merge
|
||||||
|
// it answers: the walk took what it reuses. Said, never fatal.
|
||||||
|
func forgetChecksOfWalk(ctx context.Context, inv *inventory.Inventory, p inventory.Plan) {
|
||||||
|
if p.WalkSaid != inventory.WalkEnded || p.Delivery == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, pull := range reuseAskOf(&p).Pulls {
|
||||||
|
said, err := forgetChecks(ctx, inv, builder.PullRef{Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: its check builds stay until the week's sweep: %v\n", p.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetChecksCommand is `forget-checks <owner> <repo> <number>`: the verb the delivery's owner asks when the forge
|
||||||
|
// says a pull request closed unmerged (novox/hq ADR 0350 §5).
|
||||||
|
func forgetChecksCommand(ctx context.Context, args []string) error {
|
||||||
|
if len(args) != 3 {
|
||||||
|
return errors.New("forget-checks <owner> <repo> <number>")
|
||||||
|
}
|
||||||
|
number, err := strconv.Atoi(args[2])
|
||||||
|
if err != nil || number <= 0 {
|
||||||
|
return fmt.Errorf("%q is not a pull request's number", args[2])
|
||||||
|
}
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
said, err := forgetChecks(ctx, open.inventory, builder.PullRef{Owner: args[0], Repo: args[1], Number: number})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(said)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/builder"
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One build per change (novox/hq ADR 0350), as the controller asks it and takes it in.
|
||||||
|
|
||||||
|
// The check of a change is asked to build what a merge of it would move, in the walk's order: a module of the
|
||||||
|
// repository checked from the merge, one standing on it from its own repository as the walk builds it, and one
|
||||||
|
// that reads the repository through a context not at all. A change that moves no module builds nothing.
|
||||||
|
func TestACheckIsAskedToBuildWhatTheMergeWouldMove(t *testing.T) {
|
||||||
|
entries := []inventory.Entry{
|
||||||
|
{Manifest: catalogue.Manifest{Module: "app"}, Source: inventory.Source{Repository: "novox/shop", Seat: "git", Path: "modules/app"}},
|
||||||
|
{Manifest: catalogue.Manifest{Module: "web"}, Source: inventory.Source{Repository: "novox/web", Seat: "git", Ref: "main"}},
|
||||||
|
{Manifest: catalogue.Manifest{Module: "docs"}, Source: inventory.Source{Repository: "novox/docs", Seat: "git"}},
|
||||||
|
}
|
||||||
|
p := link.PullUpdated{Owner: "novox", Repo: "shop", Number: 7, Base: "main", Commit: "abc"}
|
||||||
|
scope := checkScope{Reach: mergeReach{Packaging: entries[2:], Plan: inventory.Plan{Tiers: [][]string{{"app", "docs"}, {"web"}}}}}
|
||||||
|
clone := func(s inventory.Source) (string, error) { return "http://forge.invalid/" + s.Repository + ".git", nil }
|
||||||
|
|
||||||
|
got := checkBuildsOf(p, scope, entries, clone)
|
||||||
|
want := []link.CheckBuild{
|
||||||
|
{Module: "app", Path: "modules/app", Merged: true, Tier: 0},
|
||||||
|
{Module: "docs", Tier: 0, Skip: "its build reads novox/shop through a context, which a check cannot hand the merge"},
|
||||||
|
{Module: "web", Repository: "http://forge.invalid/novox/web.git", Ref: "main", Tier: 1},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("the check is asked to build\n%+v\nwant\n%+v", got, want)
|
||||||
|
}
|
||||||
|
if got := checkBuildsOf(p, checkScope{}, entries, clone); got != nil {
|
||||||
|
t.Fatalf("a change that moves no module asks for builds: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A walk's ask carries its leave to take a check's build, naming the pull requests of the merges it answers; a dry
|
||||||
|
// run never does, and neither does an ask that is not a walk's.
|
||||||
|
func TestOnlyAWalksAskTakesAChecksBuildAndADryRunNever(t *testing.T) {
|
||||||
|
p := &inventory.Plan{Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{
|
||||||
|
{Repository: "novox/shop", Number: 7}, {Repository: "http://forge.invalid/novox/web.git", Number: 3},
|
||||||
|
{Repository: "novox/shop", Number: 7}, {Repository: "novox/shop"}}}}
|
||||||
|
asked := reuseAskOf(p)
|
||||||
|
if want := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}, {Owner: "novox", Repo: "web", Number: 3}}; !reflect.DeepEqual(asked.Pulls, want) {
|
||||||
|
t.Fatalf("the walk names %+v", asked.Pulls)
|
||||||
|
}
|
||||||
|
walk := withReuse(t.Context(), asked)
|
||||||
|
if reuseAsked(walk, false) != asked {
|
||||||
|
t.Fatal("a walk's ask does not carry its leave")
|
||||||
|
}
|
||||||
|
if reuseAsked(walk, true) != nil {
|
||||||
|
t.Fatal("a dry run carries a leave to take a check's build")
|
||||||
|
}
|
||||||
|
if reuseAsked(t.Context(), false) != nil {
|
||||||
|
t.Fatal("an ask that is not a walk's carries a leave")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A walk ended lets go of its merges' check builds — only once it ended, after it took what it reuses.
|
||||||
|
func TestAWalkEndedLetsGoOfItsMergesCheckBuilds(t *testing.T) {
|
||||||
|
var forgot []builder.PullRef
|
||||||
|
was := forgetChecks
|
||||||
|
forgetChecks = func(_ context.Context, _ *inventory.Inventory, pull builder.PullRef) (string, error) {
|
||||||
|
forgot = append(forgot, pull)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { forgetChecks = was })
|
||||||
|
p := inventory.Plan{ID: "plan-1", Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{
|
||||||
|
{Repository: "novox/shop", Number: 7}}}}
|
||||||
|
|
||||||
|
p.WalkSaid = inventory.WalkStarted
|
||||||
|
forgetChecksOfWalk(t.Context(), nil, p)
|
||||||
|
p.WalkSaid = ""
|
||||||
|
forgetChecksOfWalk(t.Context(), nil, p)
|
||||||
|
if len(forgot) != 0 {
|
||||||
|
t.Fatalf("a walk not ended let go of %v, which it may still take", forgot)
|
||||||
|
}
|
||||||
|
p.WalkSaid = inventory.WalkEnded
|
||||||
|
forgetChecksOfWalk(t.Context(), nil, p)
|
||||||
|
if want := []builder.PullRef{{Owner: "novox", Repo: "shop", Number: 7}}; !reflect.DeepEqual(forgot, want) {
|
||||||
|
t.Fatalf("the walk ended let go of %v", forgot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A build in the scratch namespace is never registered: only the walk's copy in the module's own namespace is.
|
||||||
|
func TestABuildInTheScratchNamespaceIsNeverRegistered(t *testing.T) {
|
||||||
|
if got := inScratch([]link.MadeArtifact{{Name: "server", Reference: "store.internal:5000/app/server@sha256:aa"}}); got != "" {
|
||||||
|
t.Fatalf("a module's own build reads as a scratch build: %s", got)
|
||||||
|
}
|
||||||
|
scratch := "store.internal:5000/scratch/app/server@sha256:aa"
|
||||||
|
if got := inScratch([]link.MadeArtifact{{Name: "server", Reference: scratch}}); got != scratch {
|
||||||
|
t.Fatalf("a scratch build was not found: %q", got)
|
||||||
|
}
|
||||||
|
if got := inScratch([]link.MadeArtifact{{Name: "look", Reference: "http://store.internal:5000/v2/scratch/app/look/blobs/sha256:bb"}}); got == "" {
|
||||||
|
t.Fatal("a scratch archive was not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The walk says per module what it did: "reused from …" or "built: <why>" — and a build seat from before says
|
||||||
|
// neither, which is said.
|
||||||
|
func TestAWalksBuildSaysWhetherItReusedOrBuilt(t *testing.T) {
|
||||||
|
if got := (link.BuildResult{Reused: "reused from the check of novox/shop#7"}).MadeWords(); got != "reused from the check of novox/shop#7" {
|
||||||
|
t.Fatalf("said %q", got)
|
||||||
|
}
|
||||||
|
if got := (link.BuildResult{BuiltWhy: "no check built it"}).MadeWords(); got != "built: no check built it" {
|
||||||
|
t.Fatalf("said %q", got)
|
||||||
|
}
|
||||||
|
if got := (link.BuildResult{}).MadeWords(); !strings.Contains(got, "predates ADR 0350") {
|
||||||
|
t.Fatalf("said %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A walk's module the check's build stood in for is registered by the digest the check built — the copy in the
|
||||||
|
// module's namespace — and the walk keeps "reused from …" for it; its first-node gate judges that registered
|
||||||
|
// build as it judges one the walk built. A scratch reference is refused. Needs the store (MESH_TEST_POSTGRES), and
|
||||||
|
// is named in testdata/store-tests.
|
||||||
|
func TestAReusedCheckBuildIsRegisteredByItsDigestAndGatedAsABuiltOne(t *testing.T) {
|
||||||
|
g := aGateMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := g.open.inventory
|
||||||
|
gateEvery = 0
|
||||||
|
|
||||||
|
digest := "sha256:" + strings.Repeat("c", 64)
|
||||||
|
reused := func(id, reference string) link.BuildResult {
|
||||||
|
manifest := mustJSON(t, catalogue.Manifest{Module: "app", Version: "c3", Resources: []map[string]any{
|
||||||
|
{"id": "svc", "type": "container", "name": "app", "image": reference}}})
|
||||||
|
return link.BuildResult{ID: id, Repository: "novox/mesh-catalog", Path: "modules/app", Ref: "main", On: "anchor",
|
||||||
|
Commit: "c3", Manifest: manifest, Trunk: "main", OnTrunk: true, Branches: []string{"main"},
|
||||||
|
Made: []link.MadeArtifact{{Name: "server", Kind: "image", Reference: reference}},
|
||||||
|
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"},
|
||||||
|
Reused: "reused from the check of novox/mesh-catalog#9 at head abcdefabcdef (merged into trunk 123456123456): server " + digest,
|
||||||
|
ReusedFrom: &link.ReusedFrom{Pull: link.PullRef{Owner: "novox", Repo: "mesh-catalog", Number: 9},
|
||||||
|
Head: "abcdefabcdef", Trunk: "123456123456", Key: "r1:" + strings.Repeat("1", 64)}}
|
||||||
|
}
|
||||||
|
if _, _, err := takeIn(ctx, inv, reused(link.NewBuildID(time.Now()), "store.internal:5000/scratch/app/server@"+digest)); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "scratch") {
|
||||||
|
t.Fatalf("a build in the scratch namespace was registered: %v", err)
|
||||||
|
}
|
||||||
|
id := link.NewBuildID(time.Now().Add(time.Second))
|
||||||
|
result := reused(id, "store.internal:5000/app/server@"+digest)
|
||||||
|
if _, _, err := takeIn(ctx, inv, result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
current, err := inv.CurrentBuilds(ctx)
|
||||||
|
if err != nil || current["app"].Commit != "c3" {
|
||||||
|
t.Fatalf("the reused build is not what app is registered at: %+v %v", current["app"], err)
|
||||||
|
}
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(shelf["app"].Resources)
|
||||||
|
if !strings.Contains(string(raw), "app/server@"+digest) {
|
||||||
|
t.Fatalf("app is registered as %s, not by the check's digest in its own namespace", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The walk asked it: the walk keeps what its build did, and its gate judges the registered build.
|
||||||
|
asked := time.Now().UTC()
|
||||||
|
p := g.plan(t)
|
||||||
|
p.Commit = "c3"
|
||||||
|
p.Modules["app"] = &inventory.PlanModule{State: "asked", AskedAt: &asked, Build: id}
|
||||||
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
planBuiltSaying(ctx, g.open, "app", "c3", "", asked, id, result.MadeWords())
|
||||||
|
p = g.plan(t)
|
||||||
|
if m := p.Modules["app"]; m.State != "built" || !strings.HasPrefix(m.Made, "reused from the check of novox/mesh-catalog#9") {
|
||||||
|
t.Fatalf("the walk keeps %+v", m)
|
||||||
|
}
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
advancePlans(ctx, g.open)
|
||||||
|
}
|
||||||
|
p = g.plan(t)
|
||||||
|
gate := p.Modules["app"].Gate
|
||||||
|
if gate == nil || gate.To != "c3" || gate.Verdict != inventory.GatePassed {
|
||||||
|
t.Fatalf("the gate judged %+v, not the reused build", gate)
|
||||||
|
}
|
||||||
|
if sent, _, _ := inv.SentBuilds(ctx, "anchor"); sent["app"] != "c3" {
|
||||||
|
t.Fatalf("the first machine was sent %v, not the reused build", sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A walk's leave names the builds its own pull requests' verdicts made, and none when they cannot be read: the
|
||||||
|
// builder takes nothing a verdict does not name (novox/hq ADR 0350 §2).
|
||||||
|
func TestAWalksLeaveNamesTheBuildsItsPullRequestsVerdictsMade(t *testing.T) {
|
||||||
|
was := checkedBuildsOf
|
||||||
|
t.Cleanup(func() { checkedBuildsOf = was })
|
||||||
|
var asked []link.PullRef
|
||||||
|
named := []link.CheckedBuild{{Pull: link.PullRef{Owner: "novox", Repo: "shop", Number: 7}, Check: "build-1",
|
||||||
|
Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}}
|
||||||
|
checkedBuildsOf = func(_ context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
|
||||||
|
asked = pulls
|
||||||
|
return named, nil
|
||||||
|
}
|
||||||
|
pulls := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}}
|
||||||
|
if got := reuseWithVerdicts(t.Context(), &link.ReuseAsk{Pulls: pulls}); !reflect.DeepEqual(got.Builds, named) ||
|
||||||
|
!reflect.DeepEqual(asked, pulls) {
|
||||||
|
t.Fatalf("the leave names %+v, read for %+v", got.Builds, asked)
|
||||||
|
}
|
||||||
|
checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) {
|
||||||
|
return nil, errors.New("no bus")
|
||||||
|
}
|
||||||
|
if got := reuseWithVerdicts(t.Context(), &link.ReuseAsk{Pulls: pulls}); got.Builds != nil {
|
||||||
|
t.Fatalf("verdicts that could not be read named %+v", got.Builds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -282,10 +282,59 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
|
|||||||
Source: sourceOnSeat(source),
|
Source: sourceOnSeat(source),
|
||||||
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
|
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
|
||||||
Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New,
|
Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New,
|
||||||
Manifests: scope.Manifests, Judge: scope.Judge},
|
Manifests: scope.Manifests, Judge: scope.Judge, Builds: checkBuildsOf(p, scope, entries, clone)},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkBuildsOf are the modules a merge of the change would build, in the walk's order, as the check builds them
|
||||||
|
// for the walk to take (novox/hq ADR 0350 §1): a module built from the repository checked, at the branch it merges
|
||||||
|
// into, from the merge the check makes; any other — a module standing on one — from its own repository at the
|
||||||
|
// branch it follows, as the walk builds it. A module whose build reads the repository checked through a context,
|
||||||
|
// or that the merge deletes, cannot be built as the walk would: said, and nothing after it is built. A change that
|
||||||
|
// moves no module builds nothing.
|
||||||
|
func checkBuildsOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry,
|
||||||
|
clone func(inventory.Source) (string, error)) []link.CheckBuild {
|
||||||
|
if len(scope.Reach.Plan.Tiers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := link.SourceMoved{Owner: p.Owner, Repo: p.Repo, Base: p.Base, CloneURL: p.CloneURL}
|
||||||
|
byName := map[string]inventory.Entry{}
|
||||||
|
for _, e := range entries {
|
||||||
|
byName[e.Manifest.Module] = e
|
||||||
|
}
|
||||||
|
skip := map[string]string{}
|
||||||
|
for _, e := range scope.Reach.Packaging {
|
||||||
|
skip[e.Manifest.Module] = "its build reads " + p.Owner + "/" + p.Repo + " through a context, which a check " +
|
||||||
|
"cannot hand the merge"
|
||||||
|
}
|
||||||
|
for _, e := range scope.Reach.Deleted {
|
||||||
|
skip[e.Manifest.Module] = "the merge deletes it"
|
||||||
|
}
|
||||||
|
var out []link.CheckBuild
|
||||||
|
for tier, names := range scope.Reach.Plan.Tiers {
|
||||||
|
for _, name := range names {
|
||||||
|
e, known := byName[name]
|
||||||
|
b := link.CheckBuild{Module: name, Tier: tier, Skip: skip[name]}
|
||||||
|
switch {
|
||||||
|
case !known:
|
||||||
|
b.Skip = "the catalogue does not hold it"
|
||||||
|
case b.Skip != "":
|
||||||
|
case sourceIs(e.Source, m):
|
||||||
|
b.Merged, b.Path = true, e.Source.Path
|
||||||
|
default:
|
||||||
|
url, err := clone(e.Source)
|
||||||
|
if err != nil {
|
||||||
|
b.Skip = "its repository cannot be cloned: " + err.Error()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.Repository, b.Path, b.Ref = url, e.Source.Path, followedBranch(e.Source.Ref)
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// besideRefs is the ref each repository is cloned at beside a check, by the directory it is found under:
|
// besideRefs is the ref each repository is cloned at beside a check, by the directory it is found under:
|
||||||
// a core repository at the commit the mesh runs of the module built from it (`running`) — but the
|
// a core repository at the commit the mesh runs of the module built from it (`running`) — but the
|
||||||
// catalogue, whose checkout beside is what tests read its files from, at its main, what the next merge
|
// catalogue, whose checkout beside is what tests read its files from, at its main, what the next merge
|
||||||
@@ -382,6 +431,7 @@ func checkedOf(result link.BuildResult) link.Checked {
|
|||||||
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and the commits of the
|
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and the commits of the
|
||||||
// repositories read beside it.
|
// repositories read beside it.
|
||||||
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
|
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
|
||||||
|
c.Built = result.Check.Built
|
||||||
if c.Gate == nil {
|
if c.Gate == nil {
|
||||||
// A build seat from before the layers: its verdict is the gate's.
|
// A build seat from before the layers: its verdict is the gate's.
|
||||||
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
||||||
|
|||||||
@@ -348,11 +348,11 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
|||||||
if layer.From == catalogue.MeshWideLayer {
|
if layer.From == catalogue.MeshWideLayer {
|
||||||
if !meshWide[module] {
|
if !meshWide[module] {
|
||||||
meshWide[module] = true
|
meshWide[module] = true
|
||||||
f.Settings = append(f.Settings, snapshot.Settings{Module: module, Values: scrub.Values(layer.Values)})
|
f.Settings = append(f.Settings, snapshot.Settings{Module: module, Values: scrub.Values(layer.Whole())})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.Settings = append(m.Settings, snapshot.Settings{Module: module, Values: scrub.Values(layer.Values)})
|
m.Settings = append(m.Settings, snapshot.Settings{Module: module, Values: scrub.Values(layer.Whole())})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.Declaration = declarationFacts(ctx, open, n.Name, gens, gensErr, scrub)
|
m.Declaration = declarationFacts(ctx, open, n.Name, gens, gensErr, scrub)
|
||||||
|
|||||||
@@ -81,9 +81,13 @@ func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return f, err
|
return f, err
|
||||||
}
|
}
|
||||||
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
|
wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
|
||||||
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound })
|
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
|
||||||
gateSettle, gateEvery, gateBound = 0, 0, 300*time.Millisecond
|
wasWindow := gateWitnessWindow
|
||||||
|
gateWitnessWindow = 0
|
||||||
|
t.Cleanup(func() { gateWitnessWindow = wasWindow })
|
||||||
|
t.Cleanup(func() { gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound })
|
||||||
|
gateWithin, gateEvery, gateBound = time.Hour, 0, 300*time.Millisecond
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
advancePlans(ctx, b.open)
|
advancePlans(ctx, b.open)
|
||||||
|
|||||||
+211
-36
@@ -28,9 +28,12 @@ import (
|
|||||||
// that machine reported it applied. A build that applies and then does nothing, crashes, serves no
|
// that machine reported it applied. A build that applies and then does nothing, crashes, serves no
|
||||||
// tools, or breaks the machine's word to the mesh passed that test. Now the first machine is judged by
|
// tools, or breaks the machine's word to the mesh passed that test. Now the first machine is judged by
|
||||||
// the component's health — the core's health definitions, or a module's own — within ten minutes of the
|
// the component's health — the core's health definitions, or a module's own — within ten minutes of the
|
||||||
// apply: a core module passing three times over at least two minutes, a leaf module once, as soon as the
|
// apply, each module by its rule (gateRuleOf): the node-engine and the mesh's resolver passing on their third
|
||||||
// node-engine states it healthy (novox/hq ADR 0282 decision 4 as ADR 0304 decision 3 changed it). Only then
|
// healthy reading, and never later than two minutes after the send while healthy; the controller and the tool runner once, no earlier than the end of the
|
||||||
// are the rest sent.
|
// node-engine's witness window for it; mesh-delivery and a leaf module once, as soon as the node-engine states
|
||||||
|
// it healthy (novox/hq ADR 0351 and ADR 0352, changing ADR 0236 decision 3, ADR 0282 decision 4 and ADR 0304
|
||||||
|
// decision 3).
|
||||||
|
// Only then are the rest sent.
|
||||||
//
|
//
|
||||||
// **A failing gate stops the plan and puts the previous build back there.** The build is marked failed
|
// **A failing gate stops the plan and puts the previous build back there.** The build is marked failed
|
||||||
// at its gate — registration refuses it and nothing sends it again on its own — the module's registered
|
// at its gate — registration refuses it and nothing sends it again on its own — the module's registered
|
||||||
@@ -41,22 +44,30 @@ import (
|
|||||||
|
|
||||||
// The gate's bounds (to-be 45 §8). Variables so a test can judge in a second, not in minutes.
|
// The gate's bounds (to-be 45 §8). Variables so a test can judge in a second, not in minutes.
|
||||||
var (
|
var (
|
||||||
// gateSettle is how long the first machine must stay healthy, at the least.
|
// gateWithin is the longest a module under the three-reading rule that stays healthy is held: at it, the
|
||||||
gateSettle = 2 * time.Minute
|
// gate passes on the healthy readings it has, the newest healthy and none since the send not (novox/hq ADR
|
||||||
|
// 0352). Readings gateEvery apart, the first at the apply, put three inside it.
|
||||||
|
gateWithin = 2 * time.Minute
|
||||||
// gateBound is how long after the apply the build may take to become healthy.
|
// gateBound is how long after the apply the build may take to become healthy.
|
||||||
gateBound = 10 * time.Minute
|
gateBound = 10 * time.Minute
|
||||||
// gatePasses is how many consecutive judgings must find a core module healthy, gateEvery apart at the
|
// gatePasses is how many consecutive judgings must find such a core module healthy, gateEvery apart at
|
||||||
// least, after gateSettle. gateLeafPasses is a leaf module's: one, taken once the node-engine states every
|
// the least, counted from the send: it passes on the third (novox/hq ADR 0352). gateLeafPasses is a leaf module's: one, taken once the
|
||||||
// resource of it healthy by its own check, with no settle (novox/hq ADR 0282 decision 4 as ADR 0304
|
// node-engine states every resource of it healthy by its own check, with no settle (novox/hq ADR 0282
|
||||||
// decision 3 changed it, issue 524).
|
// decision 4 as ADR 0304 decision 3 changed it, issue 524).
|
||||||
gatePasses = 3
|
gatePasses = 3
|
||||||
gateLeafPasses = 1
|
gateLeafPasses = 1
|
||||||
gateLeafSettles = false
|
gateLeafSettles = false
|
||||||
gateEvery = 40 * time.Second
|
gateEvery = 40 * time.Second
|
||||||
|
// gateWitnessWindow is the node-engine's witness window for a new controller or tool runner, from its
|
||||||
|
// start (ADR 0236 decision 27, lease.ControllerWithin and lease.NodeToolsWithin): a reading of either
|
||||||
|
// counts only after it has ended, so the build is judged by the witness and the gate both (novox/hq ADR
|
||||||
|
// 0351 decisions 1 and 4).
|
||||||
|
gateWitnessWindow = max(lease.ControllerWithin, lease.NodeToolsWithin)
|
||||||
)
|
)
|
||||||
|
|
||||||
// coreModule is whether a module is judged as the core (novox/hq ADR 0282 decision 1): one on the controller's
|
// coreModule is whether a module is judged as the core (novox/hq ADR 0282 decision 1): one on the controller's
|
||||||
// own path, or one holding the mesh's resolver. Every other module is a leaf.
|
// own path, or one holding the mesh's resolver. Every other module is a leaf. Its class sets the module's
|
||||||
|
// delivery budget; how many readings its gate takes is gateRuleOf's.
|
||||||
func coreModule(module string, m catalogue.Manifest) bool {
|
func coreModule(module string, m catalogue.Manifest) bool {
|
||||||
if _, own := onTheControllersPath[module]; own {
|
if _, own := onTheControllersPath[module]; own {
|
||||||
return true
|
return true
|
||||||
@@ -69,12 +80,72 @@ func coreModule(module string, m catalogue.Manifest) bool {
|
|||||||
return coreComponent(module) != ""
|
return coreComponent(module) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// gateWants is how many passes a module needs, and whether it must stay healthy gateSettle first.
|
// gateRule is how a module's first-node gate judges it: passes healthy readings, gateEvery apart at the
|
||||||
func gateWants(module string, m catalogue.Manifest) (passes int, settles bool) {
|
// least, counted from the send or, where witnessed, from the end of the node-engine's witness window for it;
|
||||||
if coreModule(module, m) {
|
// and, where within is set, passed at within after the send on fewer, if it was healthy at every reading
|
||||||
return gatePasses, true
|
// (novox/hq ADR 0352). said names the rule in the gate's text.
|
||||||
|
type gateRule struct {
|
||||||
|
passes int
|
||||||
|
within time.Duration
|
||||||
|
witnessed bool
|
||||||
|
said string
|
||||||
}
|
}
|
||||||
return gateLeafPasses, gateLeafSettles
|
|
||||||
|
// gateRuleOf is a module's gate rule (novox/hq ADR 0351): a core module keeps three readings only where its
|
||||||
|
// build, broken on every machine, could not receive its own fix — the node-engine, which applies every send,
|
||||||
|
// and the mesh's resolver, which gives every machine the bus's name (decisions 2, 3 and 6); the bus is never
|
||||||
|
// walked through a gate but kept so (decision 7). The controller, run on one machine, and the tool runner,
|
||||||
|
// whose fix the node-engine applies, are judged by one reading after the witness window (decisions 1 and 4);
|
||||||
|
// mesh-delivery, whose fix goes the controller's own path, by one (decision 5); a leaf module by one (ADR 0304
|
||||||
|
// decision 3).
|
||||||
|
func gateRuleOf(module string, m catalogue.Manifest) gateRule {
|
||||||
|
switch {
|
||||||
|
case coreComponent(module) == lease.ComponentController:
|
||||||
|
return gateRule{passes: 1, witnessed: true, said: "the controller's rule: one reading once it is ready, " +
|
||||||
|
"after the node-engine's witness window (novox/hq ADR 0351)"}
|
||||||
|
case coreComponent(module) == lease.ComponentNodeTools:
|
||||||
|
return gateRule{passes: 1, witnessed: true, said: "the tool runner's rule: one reading once it answers, " +
|
||||||
|
"after the node-engine's witness window (novox/hq ADR 0351)"}
|
||||||
|
case module == catalogue.DeliverySeat:
|
||||||
|
return gateRule{passes: 1, said: "mesh-delivery's rule: one reading (novox/hq ADR 0351)"}
|
||||||
|
case coreModule(module, m):
|
||||||
|
return gateRule{passes: gatePasses, within: gateWithin, said: fmt.Sprintf("the core's rule: %d readings, "+
|
||||||
|
"or healthy throughout %s after the send (novox/hq ADR 0352)", gatePasses, gateWithin)}
|
||||||
|
}
|
||||||
|
r := gateRule{passes: gateLeafPasses, said: "a leaf module's rule: one reading (novox/hq ADR 0304)"}
|
||||||
|
if gateLeafSettles {
|
||||||
|
r.within = gateWithin
|
||||||
|
r.said = fmt.Sprintf("a leaf module's rule, judged as the core: %d readings, or healthy throughout %s after the send",
|
||||||
|
gateLeafPasses, gateWithin)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// gateFloor is the earliest a module's reading counts by its rule: the send and, for a witnessed module, the
|
||||||
|
// end of the node-engine's witness window on every machine judged (novox/hq ADR 0351 decisions 1 and 4). A
|
||||||
|
// module under the three-reading rule counts from the send (novox/hq ADR 0352). The window opens when the node-engine starts the build, which is before the machine reports it
|
||||||
|
// applied (applied, as the gate first read it) and, for the controller, before the new controller took the
|
||||||
|
// lease (holder): the later of those, the send's time at the least, plus the window, is never before its end.
|
||||||
|
// A controller that starts again takes the lease again, and so moves its floor on. Pure.
|
||||||
|
func gateFloor(module string, rule gateRule, since time.Time, machines []string, applied map[string]time.Time,
|
||||||
|
holder *lease.Holder) time.Time {
|
||||||
|
floor := since
|
||||||
|
if !rule.witnessed {
|
||||||
|
return floor
|
||||||
|
}
|
||||||
|
for _, n := range machines {
|
||||||
|
start := since
|
||||||
|
if at, read := applied[n]; read && at.After(start) {
|
||||||
|
start = at
|
||||||
|
}
|
||||||
|
if coreComponent(module) == lease.ComponentController && holder != nil && holder.Taken.After(start) {
|
||||||
|
start = holder.Taken
|
||||||
|
}
|
||||||
|
if end := start.Add(gateWitnessWindow); end.After(floor) {
|
||||||
|
floor = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return floor
|
||||||
}
|
}
|
||||||
|
|
||||||
// gateProbe is the registry's row for the gate's verdicts and the witnesses' rollbacks: its conditions
|
// gateProbe is the registry's row for the gate's verdicts and the witnesses' rollbacks: its conditions
|
||||||
@@ -501,6 +572,13 @@ func judgeGate(ctx context.Context, open *stores, p *inventory.Plan, module stri
|
|||||||
return judgeMoves(ctx, open, g, pairs, now)
|
return judgeMoves(ctx, open, g, pairs, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dueAtTheMost says a gate healthy since its send has reached gateWithin and not been judged since: the one
|
||||||
|
// judging that may come sooner than gateEvery after the last pass (novox/hq ADR 0352).
|
||||||
|
func dueAtTheMost(g *inventory.PlanGate, now time.Time) bool {
|
||||||
|
most := g.Since.Add(gateWithin)
|
||||||
|
return !g.Faulted && g.LastPass != nil && !now.Before(most) && g.LastPass.Before(most)
|
||||||
|
}
|
||||||
|
|
||||||
// judged is one module on one machine, as a gate judges it.
|
// judged is one module on one machine, as a gate judges it.
|
||||||
type judged struct{ module, node string }
|
type judged struct{ module, node string }
|
||||||
|
|
||||||
@@ -511,7 +589,9 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
if g.Verdict != "" {
|
if g.Verdict != "" {
|
||||||
return g.Verdict, nil
|
return g.Verdict, nil
|
||||||
}
|
}
|
||||||
if g.LastPass != nil && now.Sub(*g.LastPass) < gateEvery {
|
// A judging comes gateEvery after the last pass, and once at gateWithin after the send whatever the spacing:
|
||||||
|
// a module healthy throughout is never held past it (novox/hq ADR 0352).
|
||||||
|
if g.LastPass != nil && now.Sub(*g.LastPass) < gateEvery && !dueAtTheMost(g, now) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
for _, c := range g.Carried {
|
for _, c := range g.Carried {
|
||||||
@@ -544,6 +624,11 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
facts.sentBuilds[j.node] = builds
|
facts.sentBuilds[j.node] = builds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **A busy container on a judged machine holds the judging before it starts** (novox/hq ADR 0335 §4): the
|
||||||
|
// walk says it waits there, and its gate starts at the report that says the container replaced.
|
||||||
|
if busyWait(g, pairs, facts, now) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
// **What is wrong with a machine itself is the machine's** (novox/hq issue 281): read once for each
|
// **What is wrong with a machine itself is the machine's** (novox/hq issue 281): read once for each
|
||||||
// machine judged, apart from what is wrong with a module there, and never pinned on the module the
|
// machine judged, apart from what is wrong with a module there, and never pinned on the module the
|
||||||
// gate happens to be kept on.
|
// gate happens to be kept on.
|
||||||
@@ -568,6 +653,9 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
for _, j := range pairs {
|
for _, j := range pairs {
|
||||||
w := words[j.node]
|
w := words[j.node]
|
||||||
h, said := judgeHealth(j.module, coreComponent(j.module), shelf[j.module], j.node, *g.Since, w.facts)
|
h, said := judgeHealth(j.module, coreComponent(j.module), shelf[j.module], j.node, *g.Since, w.facts)
|
||||||
|
if why, still := busyStill(j, facts); still && (h == healthGood || h == healthPerson) {
|
||||||
|
h, said = healthNotYet, why
|
||||||
|
}
|
||||||
if h == healthGood || h == healthPerson {
|
if h == healthGood || h == healthPerson {
|
||||||
if on, named := w.on[j.module]; named {
|
if on, named := w.on[j.module]; named {
|
||||||
h, said = healthNotYet, on
|
h, said = healthNotYet, on
|
||||||
@@ -608,6 +696,51 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
why = said
|
why = said
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **Each module counts its readings by its own rule** (novox/hq ADR 0351): from when the gate first read
|
||||||
|
// each machine report its send applied, and who holds the controller lease, the earliest its reading counts.
|
||||||
|
if g.Applied == nil {
|
||||||
|
g.Applied = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
for node := range byMachine {
|
||||||
|
r, said := facts.reports[node]
|
||||||
|
if _, read := g.Applied[node]; !read && said && r.At != nil && r.Outcome == inventory.OutcomeApplied &&
|
||||||
|
facts.reportedOn(node, r) {
|
||||||
|
g.Applied[node] = *r.At
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rules, floors := map[string]gateRule{}, map[string]time.Time{}
|
||||||
|
for _, m := range modules {
|
||||||
|
var on []string
|
||||||
|
for _, j := range pairs {
|
||||||
|
if j.module == m {
|
||||||
|
on = append(on, j.node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rules[m] = gateRuleOf(m, shelf[m])
|
||||||
|
floors[m] = gateFloor(m, rules[m], *g.Since, on, g.Applied, facts.holder)
|
||||||
|
}
|
||||||
|
tallyGate(g, gateJudging{modules: modules, reading: reading, waits: waits, worst: worst, why: why,
|
||||||
|
rules: rules, floors: floors}, now)
|
||||||
|
return g.Verdict, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gateJudging is one judging's readings, as tallyGate counts them: the modules judged, each one's reading
|
||||||
|
// (the worst of its machines'), its waits for a person, the send's worst reading and why, and each module's
|
||||||
|
// rule and the earliest its reading counts.
|
||||||
|
type gateJudging struct {
|
||||||
|
modules []string
|
||||||
|
reading map[string]health
|
||||||
|
waits map[string]string
|
||||||
|
worst health
|
||||||
|
why string
|
||||||
|
rules map[string]gateRule
|
||||||
|
floors map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// tallyGate counts one judging into the gate: a pass counted, a pass missed (and why), or the verdict. Pure but
|
||||||
|
// for the gate it writes.
|
||||||
|
func tallyGate(g *inventory.PlanGate, j gateJudging, now time.Time) {
|
||||||
|
modules, reading, waits, worst, why := j.modules, j.reading, j.waits, j.worst, j.why
|
||||||
if g.Healthy == nil {
|
if g.Healthy == nil {
|
||||||
g.Healthy = map[string]int{}
|
g.Healthy = map[string]int{}
|
||||||
}
|
}
|
||||||
@@ -623,38 +756,53 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
delete(g.HealthyAt, m)
|
delete(g.HealthyAt, m)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// **A pass is counted only gateEvery after the one before** (issue 318 review): a send judged more
|
|
||||||
// often while another module beside it is not yet healthy counts no faster.
|
|
||||||
if at, counted := g.HealthyAt[m]; !counted || now.Sub(at) >= gateEvery {
|
|
||||||
g.Healthy[m]++
|
|
||||||
g.HealthyAt[m] = now
|
|
||||||
}
|
|
||||||
if w := waits[m]; w != "" {
|
if w := waits[m]; w != "" {
|
||||||
if g.Waits == nil {
|
if g.Waits == nil {
|
||||||
g.Waits = map[string]string{}
|
g.Waits = map[string]string{}
|
||||||
}
|
}
|
||||||
g.Waits[m] = w
|
g.Waits[m] = w
|
||||||
}
|
}
|
||||||
|
// **A reading counts only from the module's floor** (novox/hq ADR 0351 decisions 1 and 4): the
|
||||||
|
// controller's and the tool runner's after the witness window, any other module's from the send.
|
||||||
|
if now.Before(j.floors[m]) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
settled := now.Sub(*g.Since) >= gateSettle
|
// **A pass is counted only gateEvery after the one before** (issue 318 review): a send judged more
|
||||||
// What the send needs is what its most demanding module needs: a send carrying a core module is judged
|
// often while another module beside it is not yet healthy counts no faster.
|
||||||
// as the core.
|
if at, counted := g.HealthyAt[m]; !counted || now.Sub(at) >= gateEvery {
|
||||||
wants, settles := 0, false
|
g.Healthy[m]++
|
||||||
|
g.HealthyAt[m] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What the send needs is what its most demanding module needs — the most readings, then the latest
|
||||||
|
// floor: a send carrying the node-engine is judged by its three readings, or by being healthy throughout
|
||||||
|
// its two minutes (novox/hq ADR 0352).
|
||||||
|
wants, floor, within, said := 0, time.Time{}, time.Duration(0), ""
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
p, s := gateWants(m, shelf[m])
|
r, f := j.rules[m], j.floors[m]
|
||||||
wants, settles = max(wants, p), settles || s
|
if r.passes > wants || (r.passes == wants && f.After(floor)) {
|
||||||
|
wants, within, said = r.passes, r.within, r.said
|
||||||
|
}
|
||||||
|
if f.After(floor) {
|
||||||
|
floor = f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(modules) == 0 {
|
if len(modules) == 0 {
|
||||||
// A send judged on no machine of its modules (its first machine not running them) has no class to
|
// A send judged on no machine of its modules (its first machine not running them) has no class to
|
||||||
// be judged by: it is judged as the core, never passed at once without a reading.
|
// be judged by: it is judged as the core, never passed at once without a reading.
|
||||||
wants, settles = gatePasses, true
|
wants, floor, within = gatePasses, *g.Since, gateWithin
|
||||||
|
said = fmt.Sprintf("the core's rule: %d readings, or healthy throughout %s after the send", gatePasses, gateWithin)
|
||||||
|
}
|
||||||
|
// heldLongEnough is a rule's most: within after the send, healthy at every reading since it.
|
||||||
|
heldLongEnough := func(within time.Duration) bool {
|
||||||
|
return within > 0 && !g.Faulted && !now.Before(g.Since.Add(within))
|
||||||
}
|
}
|
||||||
g.Wants = wants
|
g.Wants = wants
|
||||||
// A module's own pass is its own class's: a leaf beside a core module that fails keeps its build after
|
// A module's own pass is its own rule's: a leaf beside a core module that fails keeps its build after
|
||||||
// its one reading, and only what did not pass is put back (issue 318's rule, by class since issue 524).
|
// its one reading, and only what did not pass is put back (issue 318's rule, by class since issue 524).
|
||||||
passedAlone := func(m string) bool {
|
passedAlone := func(m string) bool {
|
||||||
p, s := gateWants(m, shelf[m])
|
r := j.rules[m]
|
||||||
return (settled || !s) && reading[m] <= healthPerson && g.Healthy[m] >= p
|
return reading[m] <= healthPerson && (g.Healthy[m] >= r.passes || (g.Healthy[m] >= 1 && heldLongEnough(r.within)))
|
||||||
}
|
}
|
||||||
// fail decides the send failed: what passed on its own keeps its pass, everything else is put back.
|
// fail decides the send failed: what passed on its own keeps its pass, everything else is put back.
|
||||||
fail := func(why string) {
|
fail := func(why string) {
|
||||||
@@ -671,6 +819,7 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
pastBound := now.Sub(*g.Since) > gateBound
|
pastBound := now.Sub(*g.Since) > gateBound
|
||||||
switch {
|
switch {
|
||||||
case worst == healthBroken:
|
case worst == healthBroken:
|
||||||
|
g.Faulted = true
|
||||||
g.Read(now, false, g.BrokenWhy)
|
g.Read(now, false, g.BrokenWhy)
|
||||||
var judging []string
|
var judging []string
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
@@ -689,25 +838,36 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
case worst == healthWaiting:
|
case worst == healthWaiting:
|
||||||
// Waiting on a provider that is unhealthy: not a pass, and not a failure at the bound either —
|
// Waiting on a provider that is unhealthy: not a pass, and not a failure at the bound either —
|
||||||
// the provider's own condition says what is wrong (ADR 0240 rule 5).
|
// the provider's own condition says what is wrong (ADR 0240 rule 5).
|
||||||
|
g.Faulted = g.Faulted || g.HealthySeen // not healthy after a healthy reading; before one, still starting
|
||||||
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing
|
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing
|
||||||
g.Read(now, false, why)
|
g.Read(now, false, why)
|
||||||
case worst == healthNotYet:
|
case worst == healthNotYet:
|
||||||
|
g.Faulted = g.Faulted || g.HealthySeen // not healthy after a healthy reading; before one, still starting
|
||||||
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing
|
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing
|
||||||
g.Read(now, false, why)
|
g.Read(now, false, why)
|
||||||
if pastBound {
|
if pastBound {
|
||||||
fail(fmt.Sprintf("not healthy within %s of its apply: %s", gateBound, why))
|
fail(fmt.Sprintf("not healthy within %s of its apply: %s", gateBound, why))
|
||||||
}
|
}
|
||||||
|
case now.Before(floor):
|
||||||
|
// Healthy before its rule lets a reading count: not a pass yet, and not a fault (novox/hq ADR 0351).
|
||||||
|
g.HealthySeen = true
|
||||||
|
// The bound still holds: a module that cannot be read healthy after its floor fails at it.
|
||||||
|
g.Last, g.Failing = fmt.Sprintf("healthy, and a reading counts from %s by %s",
|
||||||
|
floor.UTC().Format(time.RFC3339), said), nil
|
||||||
|
if pastBound {
|
||||||
|
fail(fmt.Sprintf("not judged within %s of its apply: %s", gateBound, g.Last))
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
// Healthy, or waiting for a person (ADR 0254): a pass, the wait carried along in the verdict.
|
// Healthy, or waiting for a person (ADR 0254): a pass, the wait carried along in the verdict.
|
||||||
|
g.HealthySeen = true
|
||||||
g.Passes++
|
g.Passes++
|
||||||
g.LastPass, g.Last, g.Failing = &now, "", nil
|
g.LastPass, g.Last, g.Failing = &now, "", nil
|
||||||
g.Read(now, true, "")
|
g.Read(now, true, "")
|
||||||
if g.Passes >= wants && (settled || !settles) {
|
if g.Passes >= wants || heldLongEnough(within) {
|
||||||
decide(g, inventory.GatePassed, fmt.Sprintf("healthy %d times over %s", g.Passes,
|
decide(g, inventory.GatePassed, fmt.Sprintf("healthy %d times over %s, by %s", g.Passes,
|
||||||
now.Sub(*g.Since).Round(time.Second))+waitsSaid(g.Waits), now)
|
now.Sub(*g.Since).Round(time.Second), said)+waitsSaid(g.Waits), now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return g.Verdict, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// judgedCommits is the commit of each module a gate judges: the gate's own To for its module, and each
|
// judgedCommits is the commit of each module a gate judges: the gate's own To for its module, and each
|
||||||
@@ -1283,6 +1443,12 @@ func probeGates(ctx context.Context, d *doctor) ([]conditions.Observation, error
|
|||||||
}
|
}
|
||||||
// A release held after a failed one, while builds still wait for a gate (ADR 0236).
|
// A release held after a failed one, while builds still wait for a gate (ADR 0236).
|
||||||
out = append(out, backlogObservation()...)
|
out = append(out, backlogObservation()...)
|
||||||
|
// And a rest that went silent (novox/hq ADR 0353 §4).
|
||||||
|
silent, err := silentObservation(ctx, d.open.inventory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, silent...)
|
||||||
return sortedFound(dedupeObservations(out)), nil
|
return sortedFound(dedupeObservations(out)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1372,6 +1538,15 @@ func gateLine(g *inventory.PlanGate) string {
|
|||||||
case inventory.GateFailed:
|
case inventory.GateFailed:
|
||||||
what = "FAILED"
|
what = "FAILED"
|
||||||
}
|
}
|
||||||
|
switch {
|
||||||
|
case g.Verdict == "" && g.Pending:
|
||||||
|
what = "kept, not yet sent" // novox/hq ADR 0353 §8
|
||||||
|
case g.Verdict == "" && g.Silent:
|
||||||
|
what = "silent on its send, left" // ADR 0353 §4
|
||||||
|
}
|
||||||
|
if g.Verdict == "" && g.Waiting() {
|
||||||
|
what = "waits for a busy container on " + g.Busy.Machine
|
||||||
|
}
|
||||||
line := fmt.Sprintf("gate on %s: %s", strings.Join(g.Machines, ", "), what)
|
line := fmt.Sprintf("gate on %s: %s", strings.Join(g.Machines, ", "), what)
|
||||||
if g.Component != "" {
|
if g.Component != "" {
|
||||||
line += " (core: " + g.Component + ")"
|
line += " (core: " + g.Component + ")"
|
||||||
|
|||||||
@@ -21,11 +21,15 @@ import (
|
|||||||
// wait, and a provider waiting for a login says who waits on it.
|
// wait, and a provider waiting for a login says who waits on it.
|
||||||
|
|
||||||
// withGateBounds sets the gate's bounds for one test.
|
// withGateBounds sets the gate's bounds for one test.
|
||||||
func withGateBounds(t *testing.T, settle, every, bound time.Duration) {
|
func withGateBounds(t *testing.T, within, every, bound time.Duration) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
|
wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
|
||||||
gateSettle, gateEvery, gateBound = settle, every, bound
|
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
|
||||||
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound })
|
wasWindow := gateWitnessWindow
|
||||||
|
gateWitnessWindow = 0
|
||||||
|
t.Cleanup(func() { gateWitnessWindow = wasWindow })
|
||||||
|
gateWithin, gateEvery, gateBound = within, every, bound
|
||||||
|
t.Cleanup(func() { gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound })
|
||||||
}
|
}
|
||||||
|
|
||||||
// factsOn is a judging's facts for one machine that applied its send: the node tools answer, and what it says
|
// factsOn is a judging's facts for one machine that applied its send: the node tools answer, and what it says
|
||||||
@@ -56,7 +60,7 @@ func TestAFaultBeforeTheSettleTimeWaitsForTheModuleBesideIt(t *testing.T) {
|
|||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
withGateBounds(t, 150*time.Millisecond, 20*time.Millisecond, 10*time.Second)
|
withGateBounds(t, time.Hour, 20*time.Millisecond, 10*time.Second)
|
||||||
since := time.Now().Add(-time.Second)
|
since := time.Now().Add(-time.Second)
|
||||||
base := factsOn(t, "laptop", since, healthyContainer("app"))
|
base := factsOn(t, "laptop", since, healthyContainer("app"))
|
||||||
wasGather := gatherGateFacts
|
wasGather := gatherGateFacts
|
||||||
@@ -97,7 +101,7 @@ const broker_runtime = "node-tools"
|
|||||||
func TestAPassIsCountedNoFasterThanTheGatesSpacing(t *testing.T) {
|
func TestAPassIsCountedNoFasterThanTheGatesSpacing(t *testing.T) {
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
withGateBounds(t, 0, time.Hour, time.Hour)
|
withGateBounds(t, time.Hour, time.Hour, time.Hour)
|
||||||
since := time.Now().Add(-time.Minute)
|
since := time.Now().Add(-time.Minute)
|
||||||
base := factsOn(t, "laptop", since, healthyContainer("app"), inventory.ResourceHealth{Module: "late",
|
base := factsOn(t, "laptop", since, healthyContainer("app"), inventory.ResourceHealth{Module: "late",
|
||||||
Resource: "late.web", Kind: "container", Target: "late", State: link.StateUnhealthy, Reason: "down"})
|
Resource: "late.web", Kind: "container", Target: "late", State: link.StateUnhealthy, Reason: "down"})
|
||||||
@@ -376,7 +380,7 @@ func TestABrokenModuleIsPutBackAtOnceAndTheOneBesideItGetsItsOwnVerdict(t *testi
|
|||||||
f.health = map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now, Resources: []inventory.ResourceHealth{app}}}
|
f.health = map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now, Resources: []inventory.ResourceHealth{app}}}
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
withGateBounds(t, 100*time.Millisecond, 0, 10*time.Second)
|
withGateBounds(t, time.Hour, 0, 10*time.Second)
|
||||||
|
|
||||||
release := func() inventory.Plan {
|
release := func() inventory.Plan {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The first-node gate's readings by module (novox/hq ADR 0351, ADR 0352): the controller and the tool runner pass
|
||||||
|
// on one reading after the node-engine's witness window, mesh-delivery on one, the node-engine and the mesh's
|
||||||
|
// resolver on their third healthy reading and never later than two minutes while healthy; the bus is kept so.
|
||||||
|
|
||||||
|
// withTheGatesTimes sets the gate's production bounds for a test, whatever another test left.
|
||||||
|
func withTheGatesTimes(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
wasWithin, wasEvery, wasBound, wasWindow := gateWithin, gateEvery, gateBound, gateWitnessWindow
|
||||||
|
wasPasses, wasLeafPasses, wasLeafSettles := gatePasses, gateLeafPasses, gateLeafSettles
|
||||||
|
gateWithin, gateEvery, gateBound, gateWitnessWindow = 2*time.Minute, 40*time.Second, 10*time.Minute, 60*time.Second
|
||||||
|
gatePasses, gateLeafPasses, gateLeafSettles = 3, 1, false
|
||||||
|
t.Cleanup(func() {
|
||||||
|
gateWithin, gateEvery, gateBound, gateWitnessWindow = wasWithin, wasEvery, wasBound, wasWindow
|
||||||
|
gatePasses, gateLeafPasses, gateLeafSettles = wasPasses, wasLeafPasses, wasLeafSettles
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachModuleHasItsGateRule(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
for _, c := range []struct {
|
||||||
|
module string
|
||||||
|
claims []catalogue.Claim
|
||||||
|
passes int
|
||||||
|
within time.Duration
|
||||||
|
witnessed bool
|
||||||
|
said string
|
||||||
|
}{
|
||||||
|
{"mesh-controller", nil, 1, 0, true, "the controller's rule"},
|
||||||
|
{"node-tools", nil, 1, 0, true, "the tool runner's rule"},
|
||||||
|
{catalogue.DeliverySeat, nil, 1, 0, false, "mesh-delivery's rule"},
|
||||||
|
{"mesh-host", nil, 3, 2 * time.Minute, false, "the core's rule"},
|
||||||
|
{"dnsmasq", []catalogue.Claim{{Name: resolverSeat}}, 3, 2 * time.Minute, false, "the core's rule"},
|
||||||
|
{"nats", nil, 3, 2 * time.Minute, false, "the core's rule"},
|
||||||
|
{"app", nil, 1, 0, false, "a leaf module's rule"},
|
||||||
|
} {
|
||||||
|
r := gateRuleOf(c.module, catalogue.Manifest{Module: c.module, Claims: c.claims})
|
||||||
|
if r.passes != c.passes || r.within != c.within || r.witnessed != c.witnessed || !strings.HasPrefix(r.said, c.said) {
|
||||||
|
t.Errorf("%s: %+v; want %d readings, within %s, witnessed %v, said %q", c.module, r, c.passes, c.within,
|
||||||
|
c.witnessed, c.said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every module that was core stays core for its delivery budget and its walk: only its readings change.
|
||||||
|
func TestTheCoreStaysTheCore(t *testing.T) {
|
||||||
|
for _, m := range []string{"mesh-controller", "mesh-host", "node-tools", "nats", catalogue.DeliverySeat} {
|
||||||
|
if !coreModule(m, catalogue.Manifest{Module: m}) {
|
||||||
|
t.Errorf("%s is no longer judged as the core for its class", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !coreModule("dnsmasq", catalogue.Manifest{Module: "dnsmasq", Claims: []catalogue.Claim{{Name: resolverSeat}}}) {
|
||||||
|
t.Error("the resolver's holder is no longer judged as the core for its class")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gateOn judges one module on one machine, healthy at every reading.
|
||||||
|
type gateOn struct {
|
||||||
|
module, node string
|
||||||
|
g *inventory.PlanGate
|
||||||
|
holder *lease.Holder
|
||||||
|
}
|
||||||
|
|
||||||
|
func aGateOn(module, node string, since time.Time) *gateOn {
|
||||||
|
return &gateOn{module: module, node: node, g: &inventory.PlanGate{Machines: []string{node}, Since: &since,
|
||||||
|
Applied: map[string]time.Time{}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *gateOn) read(at time.Time, h health, why string) {
|
||||||
|
r := gateRuleOf(o.module, catalogue.Manifest{Module: o.module})
|
||||||
|
floor := gateFloor(o.module, r, *o.g.Since, []string{o.node}, o.g.Applied, o.holder)
|
||||||
|
tallyGate(o.g, gateJudging{modules: []string{o.module}, reading: map[string]health{o.module: h},
|
||||||
|
worst: h, why: why, rules: map[string]gateRule{o.module: r}, floors: map[string]time.Time{o.module: floor}}, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheControllerPassesOnOneReadingAfterTheWitnessWindow(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
sent := time.Date(2026, 10, 13, 20, 0, 0, 0, time.UTC)
|
||||||
|
o := aGateOn("mesh-controller", "novox", sent)
|
||||||
|
o.g.Applied["novox"] = sent.Add(15 * time.Second)
|
||||||
|
o.holder = &lease.Holder{Instance: "new", Taken: sent.Add(20 * time.Second)}
|
||||||
|
|
||||||
|
// Ready, but inside the witness window, which opened before it took the lease: not counted.
|
||||||
|
for _, after := range []time.Duration{30 * time.Second, 60 * time.Second, 79 * time.Second} {
|
||||||
|
o.read(sent.Add(after), healthGood, "")
|
||||||
|
if o.g.Verdict != "" || o.g.Passes != 0 || !strings.Contains(o.g.Last, "counts from") {
|
||||||
|
t.Fatalf("at %s: %+v; want no reading counted inside the witness window", after, o.g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.read(sent.Add(80*time.Second), healthGood, "")
|
||||||
|
if o.g.Verdict != inventory.GatePassed || o.g.Passes != 1 || o.g.Wants != 1 {
|
||||||
|
t.Fatalf("after the witness window: %+v; want passed on one reading", o.g)
|
||||||
|
}
|
||||||
|
if !strings.Contains(o.g.Why, "healthy 1 times") || !strings.Contains(o.g.Why, "the controller's rule") ||
|
||||||
|
!strings.Contains(o.g.Why, "ADR 0351") {
|
||||||
|
t.Errorf("the verdict says %q; want the rule it applied", o.g.Why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A controller that starts again takes the lease again: its witness window starts again, and so does the
|
||||||
|
// gate's wait.
|
||||||
|
func TestAControllerThatStartsAgainWaitsOutItsNewWindow(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
sent := time.Now()
|
||||||
|
o := aGateOn("mesh-controller", "novox", sent)
|
||||||
|
o.g.Applied["novox"] = sent.Add(10 * time.Second)
|
||||||
|
o.holder = &lease.Holder{Instance: "again", Taken: sent.Add(70 * time.Second)}
|
||||||
|
o.read(sent.Add(90*time.Second), healthGood, "")
|
||||||
|
if o.g.Verdict != "" {
|
||||||
|
t.Fatalf("passed %s after a start the witness still judges: %+v", 20*time.Second, o.g)
|
||||||
|
}
|
||||||
|
o.read(sent.Add(130*time.Second), healthGood, "")
|
||||||
|
if o.g.Verdict != inventory.GatePassed {
|
||||||
|
t.Fatalf("not passed after the new window: %+v", o.g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the lease's word (a controller carried by another gate), the window counts from the applied report,
|
||||||
|
// and from the send at the least.
|
||||||
|
func TestTheWitnessWindowCountsFromTheSendAtTheLeast(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
sent := time.Now()
|
||||||
|
r := gateRuleOf("mesh-controller", catalogue.Manifest{})
|
||||||
|
if f := gateFloor("mesh-controller", r, sent, []string{"novox"}, nil, nil); !f.Equal(sent.Add(time.Minute)) {
|
||||||
|
t.Errorf("floor %s after the send; want 1m", f.Sub(sent))
|
||||||
|
}
|
||||||
|
if f := gateFloor("node-tools", gateRuleOf("node-tools", catalogue.Manifest{}), sent, []string{"ace"},
|
||||||
|
map[string]time.Time{"ace": sent.Add(12 * time.Second)}, nil); !f.Equal(sent.Add(72 * time.Second)) {
|
||||||
|
t.Errorf("the tool runner's floor %s after the send; want 1m12s", f.Sub(sent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A controller that a witness put back, or that never becomes ready, fails its gate as before.
|
||||||
|
func TestAControllerThatFailsIsStillFailed(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
sent := time.Now()
|
||||||
|
o := aGateOn("mesh-controller", "novox", sent)
|
||||||
|
o.read(sent.Add(30*time.Second), healthBroken, "the witness on novox judged the controller and rolled-back")
|
||||||
|
if o.g.Verdict != inventory.GateFailed {
|
||||||
|
t.Fatalf("a controller put back by its witness: %+v; want failed", o.g)
|
||||||
|
}
|
||||||
|
o = aGateOn("mesh-controller", "novox", sent)
|
||||||
|
o.read(sent.Add(5*time.Minute), healthNotYet, "the controller holding the lease does not say it is ready")
|
||||||
|
if o.g.Verdict != "" {
|
||||||
|
t.Fatalf("failed before the bound: %+v", o.g)
|
||||||
|
}
|
||||||
|
o.read(sent.Add(10*time.Minute+time.Second), healthNotYet, "the controller holding the lease does not say it is ready")
|
||||||
|
if o.g.Verdict != inventory.GateFailed || !strings.Contains(o.g.Why, "not healthy within") {
|
||||||
|
t.Fatalf("never ready: %+v; want failed at the bound", o.g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// threeReadings judges a module under the three-reading rule on one machine, the send at sent.
|
||||||
|
func threeReadings(t *testing.T, module string, sent time.Time) (*inventory.PlanGate, func(time.Duration, health)) {
|
||||||
|
t.Helper()
|
||||||
|
g := &inventory.PlanGate{Machines: []string{"ace"}, Since: &sent}
|
||||||
|
rule := gateRuleOf(module, catalogue.Manifest{Module: module, Claims: []catalogue.Claim{{Name: resolverSeat}}})
|
||||||
|
floor := gateFloor(module, rule, sent, []string{"ace"}, nil, nil)
|
||||||
|
return g, func(after time.Duration, h health) {
|
||||||
|
why := ""
|
||||||
|
if h != healthGood {
|
||||||
|
why = "the node-engine reports another build"
|
||||||
|
}
|
||||||
|
tallyGate(g, gateJudging{modules: []string{module}, reading: map[string]health{module: h}, worst: h, why: why,
|
||||||
|
rules: map[string]gateRule{module: rule}, floors: map[string]time.Time{module: floor}}, sent.Add(after))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A healthy node-engine or resolver passes at its third healthy reading, 40 s apart from the apply: before two
|
||||||
|
// minutes (novox/hq ADR 0352).
|
||||||
|
func TestTheNodeEngineAndTheResolverPassAtTheirThirdReading(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
for _, module := range []string{"mesh-host", "dnsmasq"} {
|
||||||
|
g, judge := threeReadings(t, module, time.Now())
|
||||||
|
judge(15*time.Second, healthGood)
|
||||||
|
judge(55*time.Second, healthGood)
|
||||||
|
if g.Verdict != "" || g.Passes != 2 {
|
||||||
|
t.Fatalf("%s: after two readings %+v; want still judging", module, g)
|
||||||
|
}
|
||||||
|
judge(95*time.Second, healthGood)
|
||||||
|
if g.Verdict != inventory.GatePassed || g.Passes != 3 || g.Wants != 3 ||
|
||||||
|
!strings.Contains(g.Why, "over 1m35s") || !strings.Contains(g.Why, "the core's rule: 3 readings") {
|
||||||
|
t.Fatalf("%s: after three readings %+v; want passed at 1m35s by the core's rule", module, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One healthy throughout whose readings came late passes at two minutes on the readings it has; the judging at
|
||||||
|
// two minutes comes sooner than the spacing after the last pass.
|
||||||
|
func TestAHealthyNodeEngineIsNeverHeldPastTwoMinutes(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
sent := time.Now()
|
||||||
|
last := sent.Add(100 * time.Second)
|
||||||
|
held := &inventory.PlanGate{Since: &sent, LastPass: &last}
|
||||||
|
if dueAtTheMost(held, sent.Add(119*time.Second)) || !dueAtTheMost(held, sent.Add(2*time.Minute)) {
|
||||||
|
t.Fatal("the judging at two minutes is not due, or is due before")
|
||||||
|
}
|
||||||
|
held.Faulted = true
|
||||||
|
if dueAtTheMost(held, sent.Add(2*time.Minute)) {
|
||||||
|
t.Fatal("a send that was not healthy at a reading is judged at two minutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
g, judge := threeReadings(t, "mesh-host", time.Now())
|
||||||
|
judge(100*time.Second, healthGood)
|
||||||
|
judge(2*time.Minute, healthGood)
|
||||||
|
if g.Verdict != inventory.GatePassed || g.Passes != 2 {
|
||||||
|
t.Fatalf("late readings, healthy throughout, at two minutes: %+v; want passed on two", g)
|
||||||
|
}
|
||||||
|
g, judge = threeReadings(t, "mesh-host", time.Now())
|
||||||
|
judge(2*time.Minute, healthGood)
|
||||||
|
if g.Verdict != inventory.GatePassed || g.Passes != 1 {
|
||||||
|
t.Fatalf("one healthy reading, at two minutes: %+v; want passed on it", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reading not yet healthy before the first healthy one is the build still starting: the two minutes still hold.
|
||||||
|
func TestANodeEngineStillStartingKeepsItsTwoMinutes(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
g, judge := threeReadings(t, "mesh-host", time.Now())
|
||||||
|
judge(10*time.Second, healthNotYet)
|
||||||
|
judge(100*time.Second, healthGood)
|
||||||
|
judge(2*time.Minute, healthGood)
|
||||||
|
if g.Verdict != inventory.GatePassed || g.Faulted {
|
||||||
|
t.Fatalf("%+v; want passed at two minutes", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reading that is not healthy after a healthy one takes the two minutes away: the module needs its three readings, and one
|
||||||
|
// never healthy fails at the bound and is put back; one found broken fails.
|
||||||
|
func TestANodeEngineThatFailsAReadingIsNotPassedAtTwoMinutes(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
g, judge := threeReadings(t, "mesh-host", time.Now())
|
||||||
|
judge(20*time.Second, healthNotYet)
|
||||||
|
judge(60*time.Second, healthGood)
|
||||||
|
judge(100*time.Second, healthGood)
|
||||||
|
judge(2*time.Minute+10*time.Second, healthGood)
|
||||||
|
if g.Verdict != inventory.GatePassed || g.Passes != 3 {
|
||||||
|
t.Fatalf("%+v; want passed only on its third healthy reading", g)
|
||||||
|
}
|
||||||
|
g, judge = threeReadings(t, "mesh-host", time.Now())
|
||||||
|
judge(60*time.Second, healthGood)
|
||||||
|
judge(100*time.Second, healthNotYet)
|
||||||
|
judge(2*time.Minute, healthGood)
|
||||||
|
if g.Verdict != "" || !g.Faulted {
|
||||||
|
t.Fatalf("passed at two minutes after a reading that was not healthy: %+v", g)
|
||||||
|
}
|
||||||
|
judge(10*time.Minute+time.Second, healthNotYet)
|
||||||
|
if g.Verdict != inventory.GateFailed || !strings.Contains(g.Why, "not healthy within") {
|
||||||
|
t.Fatalf("never healthy again: %+v; want failed at the bound", g)
|
||||||
|
}
|
||||||
|
g, judge = threeReadings(t, "mesh-host", time.Now())
|
||||||
|
judge(60*time.Second, healthGood)
|
||||||
|
judge(100*time.Second, healthBroken)
|
||||||
|
if g.Verdict != inventory.GateFailed {
|
||||||
|
t.Fatalf("a broken reading: %+v; want failed, to be put back", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tool runner passes on one reading after its witness window; mesh-delivery on one, at once.
|
||||||
|
func TestTheToolRunnerAndMeshDeliveryPassOnOneReading(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
sent := time.Now()
|
||||||
|
o := aGateOn("node-tools", "ace", sent)
|
||||||
|
o.g.Applied["ace"] = sent.Add(10 * time.Second)
|
||||||
|
o.read(sent.Add(30*time.Second), healthGood, "")
|
||||||
|
if o.g.Verdict != "" {
|
||||||
|
t.Fatalf("the tool runner passed inside its witness window: %+v", o.g)
|
||||||
|
}
|
||||||
|
o.read(sent.Add(70*time.Second), healthGood, "")
|
||||||
|
if o.g.Verdict != inventory.GatePassed || o.g.Passes != 1 || !strings.Contains(o.g.Why, "the tool runner's rule") {
|
||||||
|
t.Fatalf("the tool runner: %+v; want passed on one reading", o.g)
|
||||||
|
}
|
||||||
|
|
||||||
|
o = aGateOn(catalogue.DeliverySeat, "novox", sent)
|
||||||
|
o.read(sent.Add(5*time.Second), healthGood, "")
|
||||||
|
if o.g.Verdict != inventory.GatePassed || o.g.Passes != 1 || !strings.Contains(o.g.Why, "mesh-delivery's rule") {
|
||||||
|
t.Fatalf("mesh-delivery: %+v; want passed on one reading", o.g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dip after a healthy reading taken before the controller's witness floor is a fault: the send is not passed at
|
||||||
|
// two minutes (novox/hq ADR 0352 decision 3).
|
||||||
|
func TestADipAfterAnUncountedHealthyReadingIsAFault(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
sent := time.Now()
|
||||||
|
g := &inventory.PlanGate{Machines: []string{"novox"}, Since: &sent}
|
||||||
|
modules := []string{"mesh-controller", "mesh-host"}
|
||||||
|
rules, floors := map[string]gateRule{}, map[string]time.Time{}
|
||||||
|
for _, m := range modules {
|
||||||
|
rules[m] = gateRuleOf(m, catalogue.Manifest{Module: m})
|
||||||
|
}
|
||||||
|
floors["mesh-host"] = sent
|
||||||
|
floors["mesh-controller"] = sent.Add(90 * time.Second)
|
||||||
|
judge := func(after time.Duration, h health) {
|
||||||
|
tallyGate(g, gateJudging{modules: modules, reading: map[string]health{"mesh-controller": h, "mesh-host": h},
|
||||||
|
worst: h, why: "not yet", rules: rules, floors: floors}, sent.Add(after))
|
||||||
|
}
|
||||||
|
judge(30*time.Second, healthGood)
|
||||||
|
if g.Passes != 0 {
|
||||||
|
t.Fatalf("a reading before the controller's floor was counted: %+v", g)
|
||||||
|
}
|
||||||
|
judge(70*time.Second, healthNotYet)
|
||||||
|
judge(2*time.Minute, healthGood)
|
||||||
|
if !g.Faulted || g.Verdict != "" {
|
||||||
|
t.Fatalf("%+v; want the dip a fault, and no pass at two minutes", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A send carrying the controller and the node-engine is judged by the node-engine's three readings.
|
||||||
|
func TestASendIsJudgedByItsMostDemandingRule(t *testing.T) {
|
||||||
|
withTheGatesTimes(t)
|
||||||
|
sent := time.Now()
|
||||||
|
g := &inventory.PlanGate{Machines: []string{"novox"}, Since: &sent}
|
||||||
|
modules := []string{"mesh-controller", "mesh-host"}
|
||||||
|
rules, floors := map[string]gateRule{}, map[string]time.Time{}
|
||||||
|
for _, m := range modules {
|
||||||
|
rules[m] = gateRuleOf(m, catalogue.Manifest{Module: m})
|
||||||
|
floors[m] = gateFloor(m, rules[m], sent, []string{"novox"}, nil, nil)
|
||||||
|
}
|
||||||
|
for _, after := range []time.Duration{70 * time.Second, 110 * time.Second} {
|
||||||
|
tallyGate(g, gateJudging{modules: modules, reading: map[string]health{"mesh-controller": healthGood,
|
||||||
|
"mesh-host": healthGood}, worst: healthGood, rules: rules, floors: floors}, sent.Add(after))
|
||||||
|
}
|
||||||
|
if g.Verdict != "" || g.Wants != 3 || g.Passes != 2 || g.Healthy["mesh-controller"] != 2 {
|
||||||
|
t.Fatalf("%+v; want the send judged by the node-engine's three readings, the controller's own counted", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,10 +124,14 @@ func aGateMesh(t *testing.T) *gateMesh {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { gatherGateFacts = wasGather })
|
t.Cleanup(func() { gatherGateFacts = wasGather })
|
||||||
|
|
||||||
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
|
wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
|
||||||
|
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
|
||||||
|
wasWindow := gateWitnessWindow
|
||||||
|
gateWitnessWindow = 0
|
||||||
|
t.Cleanup(func() { gateWitnessWindow = wasWindow })
|
||||||
// One judging per advance until a test says otherwise: a pass waits gateEvery for the next.
|
// One judging per advance until a test says otherwise: a pass waits gateEvery for the next.
|
||||||
gateSettle, gateEvery = 0, time.Hour
|
gateWithin, gateEvery = time.Hour, time.Hour
|
||||||
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound })
|
t.Cleanup(func() { gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound })
|
||||||
|
|
||||||
built := time.Now().UTC()
|
built := time.Now().UTC()
|
||||||
plan := inventory.Plan{ID: "plan-gate", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2",
|
plan := inventory.Plan{ID: "plan-gate", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2",
|
||||||
@@ -272,7 +276,7 @@ func TestABuildThatPassesItsGateRollsEverywhereUnattended(t *testing.T) {
|
|||||||
func TestALeafModulePassesOnOneHealthyReading(t *testing.T) {
|
func TestALeafModulePassesOnOneHealthyReading(t *testing.T) {
|
||||||
g := aGateMesh(t)
|
g := aGateMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
gateSettle = time.Hour // a core module could not pass for an hour
|
gateWithin = time.Hour // a core module could not pass on fewer readings for an hour
|
||||||
advancePlans(ctx, g.open) // the first machine is sent the new build
|
advancePlans(ctx, g.open) // the first machine is sent the new build
|
||||||
advancePlans(ctx, g.open) // one healthy judging
|
advancePlans(ctx, g.open) // one healthy judging
|
||||||
p := g.plan(t)
|
p := g.plan(t)
|
||||||
@@ -285,31 +289,6 @@ func TestALeafModulePassesOnOneHealthyReading(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which modules are judged as the core: those on the controller's own path and the resolver's holder, three
|
|
||||||
// readings after the settle time; every other module one (novox/hq ADR 0282 decision 1, ADR 0304 decision 3).
|
|
||||||
func TestAModuleIsJudgedByItsClass(t *testing.T) {
|
|
||||||
for _, c := range []struct {
|
|
||||||
module string
|
|
||||||
claims []catalogue.Claim
|
|
||||||
passes int
|
|
||||||
settles bool
|
|
||||||
}{
|
|
||||||
{"app", nil, 1, false},
|
|
||||||
{"nzbget", nil, 1, false},
|
|
||||||
{"mesh-controller", nil, 3, true},
|
|
||||||
{"mesh-host", nil, 3, true},
|
|
||||||
{"node-tools", nil, 3, true},
|
|
||||||
{"nats", nil, 3, true},
|
|
||||||
{catalogue.DeliverySeat, nil, 3, true},
|
|
||||||
{"dnsmasq", []catalogue.Claim{{Name: resolverSeat}}, 3, true},
|
|
||||||
} {
|
|
||||||
p, s := gateWants(c.module, catalogue.Manifest{Module: c.module, Claims: c.claims})
|
|
||||||
if p != c.passes || s != c.settles {
|
|
||||||
t.Errorf("%s: %d passes, settles %v; want %d, %v", c.module, p, s, c.passes, c.settles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A first machine that never becomes healthy fails its gate at the bound, and is put back.
|
// A first machine that never becomes healthy fails its gate at the bound, and is put back.
|
||||||
func TestABuildNeverHealthyFailsAtTheBound(t *testing.T) {
|
func TestABuildNeverHealthyFailsAtTheBound(t *testing.T) {
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestARepairByHandCarriesItsReason(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"push", map[string]any{"node": "anchor", "why": "stuck", "cause": "sent-not-reported"},
|
{"push", map[string]any{"node": "anchor", "why": "stuck", "cause": "sent-not-reported"},
|
||||||
"push anchor --wait 0 --why stuck --cause sent-not-reported"},
|
"push anchor --wait 0s --why stuck --cause sent-not-reported"},
|
||||||
{"plans", map[string]any{"close": "plan-1", "why": "the report will not come"},
|
{"plans", map[string]any{"close": "plan-1", "why": "the report will not come"},
|
||||||
"plans close plan-1 --why the report will not come"},
|
"plans close plan-1 --why the report will not come"},
|
||||||
{"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"},
|
{"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"},
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ func heldMachines(ctx context.Context, open *stores, names []string) (map[string
|
|||||||
for _, mv := range f.moves(node, modules, same, known, false) {
|
for _, mv := range f.moves(node, modules, same, known, false) {
|
||||||
if planStillToSend(plans, mv.Module, node) == "" {
|
if planStillToSend(plans, mv.Module, node) == "" {
|
||||||
why = append(why, fmt.Sprintf("%s would move from %s to %s, which has passed no gate yet — a release "+
|
why = append(why, fmt.Sprintf("%s would move from %s to %s, which has passed no gate yet — a release "+
|
||||||
"plan sends it, one machine at a time, judged", mv.Module, buildName(mv.From), buildName(mv.To)))
|
"plan sends it, one machine first and then the rest at once, judged", mv.Module, buildName(mv.From), buildName(mv.To)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(why) > 0 {
|
if len(why) > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A tier whose module is no move — made from the source every machine running it runs, so nothing is
|
||||||
|
// sent — is not waited on for a report after its marking (novox/hq issue 648). Its machines answered the
|
||||||
|
// send that last carried that build before the walk got there, and nothing will make them report again:
|
||||||
|
// the walk asks the next tier in the same step, rather than "waiting for app on anchor, laptop" for ever.
|
||||||
|
func TestANoMoveTierWaitsForNoNewReport(t *testing.T) {
|
||||||
|
judgeLeavesAsTheCore(t)
|
||||||
|
g := aGateMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := g.open.inventory
|
||||||
|
|
||||||
|
// app is the build machine, so a module of the next tier is built by it: the tier gates the next on app
|
||||||
|
// running on its machines.
|
||||||
|
for _, m := range []catalogue.Manifest{
|
||||||
|
{Module: "app", Version: "c2", Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}},
|
||||||
|
{Module: "web", Version: "w1"},
|
||||||
|
} {
|
||||||
|
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", Seat: "git",
|
||||||
|
Path: "modules/" + m.Module, BuiltFrom: m.Version, Head: m.Version, Asked: time.Now().Add(-time.Minute)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What anchor and laptop were last sent (c1) was made from the source the plan's build of c2 is.
|
||||||
|
for _, b := range []inventory.Build{
|
||||||
|
{ID: "build-1s", Module: "app", Commit: "c1", SourceFingerprint: "src1:app",
|
||||||
|
Asked: time.Now().Add(-30 * time.Second), At: time.Now().Add(-30 * time.Second)},
|
||||||
|
{ID: "build-2s", Module: "app", Commit: "c2", SourceFingerprint: "src1:app", Asked: time.Now(), At: time.Now()},
|
||||||
|
} {
|
||||||
|
b.Manifest, _ = json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||||
|
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Each machine answered that send, applied, before the walk's tier was built.
|
||||||
|
for _, n := range []string{"anchor", "laptop"} {
|
||||||
|
if _, err := inv.RecordDoing(ctx, nodeID(t, g.open, n), inventory.Doing{Node: n,
|
||||||
|
Outcome: inventory.OutcomeApplied, Declared: "d-" + n + "-c1", Applied: 1,
|
||||||
|
At: time.Now().Add(-10 * time.Second)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
built := time.Now().UTC()
|
||||||
|
p := g.plan(t)
|
||||||
|
p.Tiers = [][]string{{"app"}, {"web"}}
|
||||||
|
p.Modules["app"].Build, p.Modules["app"].BuiltAt = "build-2s", &built
|
||||||
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
advancePlans(ctx, g.open)
|
||||||
|
|
||||||
|
if len(g.sent) != 0 {
|
||||||
|
t.Fatalf("a no-move build was sent: %v", g.sent)
|
||||||
|
}
|
||||||
|
p = g.plan(t)
|
||||||
|
if s := p.Modules["app"]; s == nil || !strings.HasPrefix(s.Why, "no move") {
|
||||||
|
t.Fatalf("the build was not marked no move: %+v", s)
|
||||||
|
}
|
||||||
|
if p.Tier < 1 || strings.Contains(p.Note, "waiting for app") {
|
||||||
|
t.Fatalf("the walk waits on a no-move tier for a report nothing will cause: tier %d, %s %q", p.Tier, p.State, p.Note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine that has not answered its last send is still waited on: no move is not "never sent".
|
||||||
|
p.Tier, p.State, p.Note = 0, inventory.PlanRolling, ""
|
||||||
|
if err := inv.RecordSent(ctx, nodeID(t, g.open, "laptop"), "d-laptop-later", map[string]string{"app": "c1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
advancePlans(ctx, g.open)
|
||||||
|
p = g.plan(t)
|
||||||
|
if p.Tier != 0 || !strings.Contains(p.Note, "waiting for app on laptop") {
|
||||||
|
t.Fatalf("a machine that has not answered its last send was not waited on: tier %d, %q", p.Tier, p.Note)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/overlay"
|
||||||
|
)
|
||||||
|
|
||||||
|
// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local
|
||||||
|
// network, from what it last said of itself and the links it said face outside it. A machine that has
|
||||||
|
// said none judges to nothing, and a port reached from `local-network` is then admitted from the private
|
||||||
|
// network only, and said so.
|
||||||
|
func localNetworksOf(ctx context.Context, inv *inventory.Inventory, node string) ([]catalogue.LocalNetwork, error) {
|
||||||
|
account, had, err := inv.MachineOf(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
outward, err := inv.OutwardLinksOf(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return judgedNetworks(account, had, outward, overlay.Interface), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgedNetworks is the judging alone, from what was read.
|
||||||
|
func judgedNetworks(account inventory.MachineAccount, had bool, outward []string, tunnel string) []catalogue.LocalNetwork {
|
||||||
|
if !had || account.Machine == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
attached := make([]catalogue.AttachedNetwork, 0, len(account.Machine.Networks))
|
||||||
|
for _, n := range account.Machine.Networks {
|
||||||
|
attached = append(attached, catalogue.AttachedNetwork{Link: n.Link, Network: n.Network})
|
||||||
|
}
|
||||||
|
return catalogue.JudgeLocalNetworks(attached, outward, tunnel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// localNetworkLines is what `node` shows of a machine's attached networks (ADR 0340 §8): each one, with
|
||||||
|
// whether it counts as the local network and why, or why none is shown.
|
||||||
|
func localNetworkLines(account inventory.MachineAccount, had bool, judged []catalogue.LocalNetwork) []string {
|
||||||
|
out := []string{" its local network (what a port reached from local-network is admitted from):"}
|
||||||
|
switch {
|
||||||
|
case !had || account.Machine == nil:
|
||||||
|
return append(out, " not said — this machine has not reported what it is attached to")
|
||||||
|
case len(account.Machine.Networks) == 0:
|
||||||
|
why := account.Machine.Unknown["networks"]
|
||||||
|
if why == "" {
|
||||||
|
why = "its node-engine is older than the one that says it"
|
||||||
|
}
|
||||||
|
return append(out, " not said — "+why)
|
||||||
|
}
|
||||||
|
counted := 0
|
||||||
|
for _, j := range judged {
|
||||||
|
verdict := "not counted"
|
||||||
|
if j.Counts {
|
||||||
|
verdict = "counts"
|
||||||
|
counted++
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf(" %-22s on %-10s %s: %s", j.Network, j.Link, verdict, j.Why))
|
||||||
|
}
|
||||||
|
if counted == 0 {
|
||||||
|
out = append(out, " none counts: a port reached from local-network is admitted from the private network only")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **node shows what the local network resolved to** (novox/hq ADR 0340 §8): every attached network,
|
||||||
|
// whether it counts, and why the others do not — the public subnet named as never counted.
|
||||||
|
func TestNodeShowsWhichAttachedNetworksCountAndWhy(t *testing.T) {
|
||||||
|
account := inventory.MachineAccount{Architecture: "amd64", Kernel: "linux", Machine: &inventory.MadeOf{
|
||||||
|
Networks: []inventory.AttachedNetwork{
|
||||||
|
{Link: "eth0", Network: "192.168.1.10/24"},
|
||||||
|
{Link: "eth1", Network: "203.0.113.7/24"},
|
||||||
|
{Link: "wg-mesh", Network: "10.10.0.4/24"},
|
||||||
|
}}}
|
||||||
|
judged := judgedNetworks(account, true, []string{"eth0", "eth1"}, "wg-mesh")
|
||||||
|
text := strings.Join(localNetworkLines(account, true, judged), "\n")
|
||||||
|
for _, want := range []string{
|
||||||
|
"192.168.1.0/24", "counts: in a private range (RFC 1918, 192.168.0.0/16)",
|
||||||
|
"203.0.113.0/24", "not counted: public, never counted",
|
||||||
|
"10.10.0.0/24", "the private network's own link",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("node does not say %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine that said none, or whose node-engine is older, is said as that — never as an empty list
|
||||||
|
// that reads like a machine on no network.
|
||||||
|
func TestNodeSaysWhenNoNetworkWasSaid(t *testing.T) {
|
||||||
|
older := inventory.MachineAccount{Machine: &inventory.MadeOf{}}
|
||||||
|
if text := strings.Join(localNetworkLines(older, true, nil), "\n"); !strings.Contains(text, "older than the one that says it") {
|
||||||
|
t.Errorf("an older node-engine is not said:\n%s", text)
|
||||||
|
}
|
||||||
|
public := inventory.MachineAccount{Machine: &inventory.MadeOf{
|
||||||
|
Networks: []inventory.AttachedNetwork{{Link: "eth0", Network: "198.51.100.9/24"}}}}
|
||||||
|
judged := judgedNetworks(public, true, []string{"eth0"}, "wg-mesh")
|
||||||
|
if text := strings.Join(localNetworkLines(public, true, judged), "\n"); !strings.Contains(text, "none counts") {
|
||||||
|
t.Errorf("a machine on a public network only is not said to have no local network:\n%s", text)
|
||||||
|
}
|
||||||
|
if text := strings.Join(localNetworkLines(inventory.MachineAccount{}, false, nil), "\n"); !strings.Contains(text, "not said") {
|
||||||
|
t.Errorf("a machine that never reported is not said:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The preview of a flip says what a port from the local network keeps: the local networks it stays
|
||||||
|
// open to, or that the machine reported none (ADR 0340 §6).
|
||||||
|
func TestTheFlipPreviewSaysWhatALocalNetworkPortKeeps(t *testing.T) {
|
||||||
|
rules := []catalogue.Rule{{Port: 1883, Protocol: "tcp", From: catalogue.FromLocalNetwork, Because: []string{"mosquitto"}}}
|
||||||
|
at := inventory.Reach{Protocol: "tcp", Address: "0.0.0.0", Port: 1883}
|
||||||
|
with := derivedFilter{rules: rules, mesh: []string{"10.10.0.1"}, outwardLinks: []string{"eth0"},
|
||||||
|
local: []string{"192.168.1.0/24"}}
|
||||||
|
if fate := with.fate(at); !strings.Contains(fate, "192.168.1.0/24") {
|
||||||
|
t.Errorf("the preview says %q, without the local network it stays open to", fate)
|
||||||
|
}
|
||||||
|
with.local = nil
|
||||||
|
if fate := with.fate(at); !strings.Contains(fate, "reported none") {
|
||||||
|
t.Errorf("the preview says %q, without saying no local network was reported", fate)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,10 @@ func run() error {
|
|||||||
return replayCommand(ctx, args[1:])
|
return replayCommand(ctx, args[1:])
|
||||||
case "kill":
|
case "kill":
|
||||||
return killCommand(ctx, args[1:])
|
return killCommand(ctx, args[1:])
|
||||||
|
case "forget-checks":
|
||||||
|
return forgetChecksCommand(ctx, args[1:])
|
||||||
|
case "try":
|
||||||
|
return tryCommand(ctx, args[1:])
|
||||||
case "pause", "resume":
|
case "pause", "resume":
|
||||||
return pauseCommand(ctx, args[0], args[1:])
|
return pauseCommand(ctx, args[0], args[1:])
|
||||||
case "collection":
|
case "collection":
|
||||||
@@ -379,6 +383,13 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
|||||||
// **A merge check builds nothing** (novox/hq to-be 45 §9): its verdict is said, and nothing of it is
|
// **A merge check builds nothing** (novox/hq to-be 45 §9): its verdict is said, and nothing of it is
|
||||||
// recorded or registered.
|
// recorded or registered.
|
||||||
if result.Check != nil || result.Checked != nil {
|
if result.Check != nil || result.Checked != nil {
|
||||||
|
if isTry(result) {
|
||||||
|
// A branch tried before its pull request (novox/hq issue 686): its asker reads the outcome from the
|
||||||
|
// build's log. Never said as `checked`, so the forge's holder sets no status.
|
||||||
|
fmt.Printf("%s: a try of %s at %.8s, read with `try --id`: said to nobody, nothing taken in\n", result.ID,
|
||||||
|
result.Repository, result.Ref)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
checked(ctx, result)
|
checked(ctx, result)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -429,8 +440,14 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
|||||||
}
|
}
|
||||||
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
||||||
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||||
|
// What the walk's build did, kept on the walk whose ask it answers (novox/hq ADR 0350 §4); a build seat from
|
||||||
|
// before it says nothing, and that is what is kept.
|
||||||
|
made := result.MadeWords()
|
||||||
|
if result.Reused != "" || result.BuiltWhy != "" {
|
||||||
|
fmt.Printf("%s: %s %s\n", result.ID, manifest.Module, made)
|
||||||
|
}
|
||||||
saysWhenThePolicyActs(ctx, b.inv, manifest.Module)
|
saysWhenThePolicyActs(ctx, b.inv, manifest.Module)
|
||||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked, result.ID)
|
planBuiltSaying(ctx, b.open, manifest.Module, result.Commit, "", asked, result.ID, made)
|
||||||
// A module registered may be one a machine is now behind: `status` is composed again.
|
// A module registered may be one a machine is now behind: `status` is composed again.
|
||||||
statusFrom.nudge()
|
statusFrom.nudge()
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -550,8 +550,17 @@ func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inve
|
|||||||
out[k] = m
|
out[k] = m
|
||||||
}
|
}
|
||||||
var failures []string
|
var failures []string
|
||||||
|
// The list of predefined keys at the tree's root (novox/hq ADR 0348 §7): what the build of this tree judges each
|
||||||
|
// manifest against and registration publishes, so what the gate judges each against too. A list that is
|
||||||
|
// wrong is refused by name, and every manifest is then judged against none.
|
||||||
|
predefined, err := catalogue.ReadPredefined(tree)
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s",
|
||||||
|
catalogue.PredefinedFile, version, oneLine(err.Error())))
|
||||||
|
predefined = nil
|
||||||
|
}
|
||||||
found := map[string]string{} // module → its directory in the tree
|
found := map[string]string{} // module → its directory in the tree
|
||||||
err := filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error {
|
err = filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -575,7 +584,12 @@ func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inve
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m, err := catalogue.ParseManifest(raw)
|
m, err := catalogue.ParseManifestWith(raw, predefined)
|
||||||
|
if err == nil {
|
||||||
|
// Each template's text from the tree beside the manifest, as the build reads it back from the archive it
|
||||||
|
// packs (novox/hq ADR 0349 §2): so a change to a template alone is a change to its module.
|
||||||
|
m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(path))
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if inScope(dir) {
|
if inScope(dir) {
|
||||||
failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s",
|
failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s",
|
||||||
@@ -605,7 +619,7 @@ func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inve
|
|||||||
// As registration reads a built manifest: strictly, again (takeIn).
|
// As registration reads a built manifest: strictly, again (takeIn).
|
||||||
var raw []byte
|
var raw []byte
|
||||||
if raw, err = json.Marshal(resolved); err == nil {
|
if raw, err = json.Marshal(resolved); err == nil {
|
||||||
resolved, err = catalogue.ParseManifest(raw)
|
resolved, err = catalogue.ParseManifestWith(raw, predefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ import (
|
|||||||
func aMesh(t *testing.T) *stores {
|
func aMesh(t *testing.T) *stores {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
inventory.ForTest(t) // raises the store, migrates it, and points the environment at it
|
inventory.ForTest(t) // raises the store, migrates it, and points the environment at it
|
||||||
|
return aMeshOnTheStore(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aMeshBefore is aMesh on a store migrated only below migration `number`, with the function that runs the
|
||||||
|
// rest: for a test of what a migration that moves data does to what the mesh composes (novox/hq issue 651).
|
||||||
|
func aMeshBefore(t *testing.T, number int) (*stores, func()) {
|
||||||
|
t.Helper()
|
||||||
|
_, migrate := inventory.ForTestBefore(t, number)
|
||||||
|
return aMeshOnTheStore(t), migrate
|
||||||
|
}
|
||||||
|
|
||||||
|
// aMeshOnTheStore is the mesh aMesh makes, on the store the environment points at.
|
||||||
|
func aMeshOnTheStore(t *testing.T) *stores {
|
||||||
|
t.Helper()
|
||||||
licences.ForTest(t) // planning reaches this one too, by name and never by connection
|
licences.ForTest(t) // planning reaches this one too, by name and never by connection
|
||||||
|
|
||||||
open, err := openStores(t.Context())
|
open, err := openStores(t.Context())
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
|
|||||||
State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts,
|
State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts,
|
||||||
Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root}
|
Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root}
|
||||||
for _, w := range r.Waits {
|
for _, w := range r.Waits {
|
||||||
kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, What: w.What})
|
kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, Provision: w.Provision, What: w.What})
|
||||||
}
|
}
|
||||||
resources = append(resources, kept)
|
resources = append(resources, kept)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -127,6 +128,10 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// A manifest handed over by hand takes each template's text from the tree beside it (novox/hq ADR 0349 §2).
|
||||||
|
if m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(positionals[0])); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
from, err := whereItComesFrom(*repo, *ref, *commit, *path, *self)
|
from, err := whereItComesFrom(*repo, *ref, *commit, *path, *self)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -537,6 +542,10 @@ func settingsCommand(ctx context.Context, args []string) error {
|
|||||||
if p.SetBy != "" {
|
if p.SetBy != "" {
|
||||||
by = "; " + p.SetBy
|
by = "; " + p.SetBy
|
||||||
}
|
}
|
||||||
|
if p.Why != "" {
|
||||||
|
// A layer whose placement moved out of it (migration 0097): still the layer, as it stands.
|
||||||
|
by += "; " + p.Why
|
||||||
|
}
|
||||||
fmt.Printf("%s on %s, until %s (%s%s):\n %s\n", positionals[0], where,
|
fmt.Printf("%s on %s, until %s (%s%s):\n %s\n", positionals[0], where,
|
||||||
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, by, shown)
|
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, by, shown)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -684,7 +684,7 @@ func artifactStoreOnNetwork(ctx context.Context, inv *inventory.Inventory,
|
|||||||
if !offers {
|
if !offers {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
serves, err := servedOnNode(ctx, inv, machine, m, catalogue.ArtifactStoreProvision)
|
serves, _, err := servedOnNode(ctx, inv, machine, m, catalogue.ArtifactStoreProvision)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err)
|
return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/novox/mesh-controller/internal/conditions"
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
"github.com/novox/mesh-controller/internal/overlay"
|
||||||
"github.com/novox/mesh-controller/internal/token"
|
"github.com/novox/mesh-controller/internal/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -884,6 +885,16 @@ func showNode(ctx context.Context, stored *stores, name string) error {
|
|||||||
for _, line := range machineLines(account, had, time.Now()) {
|
for _, line := range machineLines(account, had, time.Now()) {
|
||||||
fmt.Println(line)
|
fmt.Println(line)
|
||||||
}
|
}
|
||||||
|
// And which of the networks it is attached to count as its local network, and why the others
|
||||||
|
// do not (novox/hq ADR 0340 §8).
|
||||||
|
outward, err := inv.OutwardLinksOf(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" its local network could NOT be judged: %v\n", err)
|
||||||
|
} else {
|
||||||
|
for _, line := range localNetworkLines(account, had, judgedNetworks(account, had, outward, overlay.Interface)) {
|
||||||
|
fmt.Println(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
held, err := inv.Profile(ctx, name)
|
held, err := inv.Profile(ctx, name)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -44,6 +45,28 @@ func needsOperatorKey(module, node string) string {
|
|||||||
type operatorWaitFacts struct {
|
type operatorWaitFacts struct {
|
||||||
manifests map[string]catalogue.Manifest
|
manifests map[string]catalogue.Manifest
|
||||||
given map[string]map[string]time.Time
|
given map[string]map[string]time.Time
|
||||||
|
// outside is, per provision some module offers with a credential issued outside the mesh, that provider module
|
||||||
|
// and the machines it is assigned to (novox/hq ADR 0337); pairsGiven, per "<module>@<machine>", the provisions
|
||||||
|
// it holds a value a person gave for, sealed to the keys both ends have now. Absent is not known: no wait for a
|
||||||
|
// provision of it is excused.
|
||||||
|
outside map[string]outsideProvider
|
||||||
|
pairsGiven map[string]map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// outsideProvider is the module offering a provision whose credential only a person gives, and where it runs.
|
||||||
|
type outsideProvider struct {
|
||||||
|
Module string
|
||||||
|
Machines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptLine is the one line that gives an outside provision's credential for every consumer of its provider: on
|
||||||
|
// the provider's machine when it runs on one, named as <machine> when on several or none.
|
||||||
|
func (p outsideProvider) acceptLine(provision string) string {
|
||||||
|
machine := "<its machine>"
|
||||||
|
if len(p.Machines) == 1 {
|
||||||
|
machine = p.Machines[0]
|
||||||
|
}
|
||||||
|
return inventory.AcceptLine(machine, p.Module, provision)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkWait is nil when a wait is excused, and otherwise why not, in words. Pure.
|
// checkWait is nil when a wait is excused, and otherwise why not, in words. Pure.
|
||||||
@@ -52,9 +75,18 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
|
|||||||
if !known {
|
if !known {
|
||||||
return fmt.Errorf("says it waits for %s, and the mesh holds no manifest of %s to check it against", waitNames(w), module)
|
return fmt.Errorf("says it waits for %s, and the mesh holds no manifest of %s to check it against", waitNames(w), module)
|
||||||
}
|
}
|
||||||
|
named := 0
|
||||||
|
for _, n := range []string{w.Secret, w.Setting, w.Provision} {
|
||||||
|
if n != "" {
|
||||||
|
named++
|
||||||
|
}
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case w.Secret != "" && w.Setting != "", w.Secret == "" && w.Setting == "":
|
case named != 1:
|
||||||
return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting", waitNames(w))
|
return fmt.Errorf("says it waits, naming %s, where a wait names one secret, one setting or one provision",
|
||||||
|
waitNames(w))
|
||||||
|
case w.Provision != "":
|
||||||
|
return checkProvisionWait(module, machine, w, m, f)
|
||||||
case w.Setting != "":
|
case w.Setting != "":
|
||||||
if _, declared := m.Settings[w.Setting]; !declared {
|
if _, declared := m.Settings[w.Setting]; !declared {
|
||||||
return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module)
|
return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module)
|
||||||
@@ -80,21 +112,47 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkProvisionWait excuses a wait for a provision's credential only when the store bears it out (novox/hq ADR 0337
|
||||||
|
// §6): the module requires the provision, a provider offers it with a credential issued outside the mesh, and the
|
||||||
|
// module on this machine holds no value a person gave for it under the keys both ends have now.
|
||||||
|
func checkProvisionWait(module, machine string, w inventory.Wait, m catalogue.Manifest, f operatorWaitFacts) error {
|
||||||
|
if !slices.Contains(m.Requires, w.Provision) {
|
||||||
|
return fmt.Errorf("says it waits for the provision %s, which %s does not require", w.Provision, module)
|
||||||
|
}
|
||||||
|
if _, outside := f.outside[w.Provision]; !outside {
|
||||||
|
return fmt.Errorf("says it waits for the provision %s, whose credential the mesh gives itself: only a "+
|
||||||
|
"credential issued outside the mesh waits for the operator", w.Provision)
|
||||||
|
}
|
||||||
|
given, readable := f.pairsGiven[module+"@"+machine]
|
||||||
|
if !readable {
|
||||||
|
return fmt.Errorf("says it waits for the provision %s, and what was given on %s could not be read", w.Provision, machine)
|
||||||
|
}
|
||||||
|
if given[w.Provision] {
|
||||||
|
return fmt.Errorf("says it waits for the provision %s, whose credential was given", w.Provision)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// waitRefusedPrefix opens every reason checkWait gives, so the words of a refused wait are told from a check's own.
|
// waitRefusedPrefix opens every reason checkWait gives, so the words of a refused wait are told from a check's own.
|
||||||
const waitRefusedPrefix = "says it waits"
|
const waitRefusedPrefix = "says it waits"
|
||||||
|
|
||||||
// waitNames is what a wait names, as "the secret x" or "the setting y".
|
// waitNames is what a wait names, as "the secret x" or "the setting y".
|
||||||
func waitNames(w inventory.Wait) string {
|
func waitNames(w inventory.Wait) string {
|
||||||
switch {
|
var names []string
|
||||||
case w.Secret != "" && w.Setting != "":
|
if w.Secret != "" {
|
||||||
return "the secret " + w.Secret + " and the setting " + w.Setting
|
names = append(names, "the secret "+w.Secret)
|
||||||
case w.Secret != "":
|
|
||||||
return "the secret " + w.Secret
|
|
||||||
case w.Setting != "":
|
|
||||||
return "the setting " + w.Setting
|
|
||||||
}
|
}
|
||||||
|
if w.Setting != "" {
|
||||||
|
names = append(names, "the setting "+w.Setting)
|
||||||
|
}
|
||||||
|
if w.Provision != "" {
|
||||||
|
names = append(names, "the provision "+w.Provision)
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
return "nothing"
|
return "nothing"
|
||||||
}
|
}
|
||||||
|
return strings.Join(names, " and ")
|
||||||
|
}
|
||||||
|
|
||||||
// checkWaiting reads one machine's resources against the facts: every waiting resource whose waits all check out is
|
// checkWaiting reads one machine's resources against the facts: every waiting resource whose waits all check out is
|
||||||
// kept as said; one with a wait that does not, or with no wait at all, is answered as unhealthy with why. Pure; the
|
// kept as said; one with a wait that does not, or with no wait at all, is answered as unhealthy with why. Pure; the
|
||||||
@@ -107,11 +165,17 @@ func checkWaiting(machine string, rs []inventory.ResourceHealth, f operatorWaitF
|
|||||||
if len(r.Waits) == 0 {
|
if len(r.Waits) == 0 {
|
||||||
why = fmt.Errorf("says it waits, and names nothing it waits for")
|
why = fmt.Errorf("says it waits, and names nothing it waits for")
|
||||||
}
|
}
|
||||||
|
waits := make([]inventory.Wait, 0, len(r.Waits))
|
||||||
for _, w := range r.Waits {
|
for _, w := range r.Waits {
|
||||||
if why == nil {
|
if why == nil {
|
||||||
why = checkWait(r.Module, machine, w, f)
|
why = checkWait(r.Module, machine, w, f)
|
||||||
}
|
}
|
||||||
|
if p, outside := f.outside[w.Provision]; outside && w.Provision != "" {
|
||||||
|
w.Act = p.acceptLine(w.Provision)
|
||||||
}
|
}
|
||||||
|
waits = append(waits, w)
|
||||||
|
}
|
||||||
|
r.Waits = waits
|
||||||
if why != nil {
|
if why != nil {
|
||||||
r.State, r.Reason = link.StateUnhealthy, why.Error()
|
r.State, r.Reason = link.StateUnhealthy, why.Error()
|
||||||
}
|
}
|
||||||
@@ -150,6 +214,10 @@ func operatorWaitSaid(module, machine string, waits []inventory.Wait) string {
|
|||||||
if w.Secret != "" {
|
if w.Secret != "" {
|
||||||
part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret)
|
part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret)
|
||||||
}
|
}
|
||||||
|
if w.Provision != "" && w.Act != "" {
|
||||||
|
// One line for the provider, which seals it to every consumer of it (ADR 0337 §3, §7).
|
||||||
|
part += fmt.Sprintf(", given for every consumer with `%s`", w.Act)
|
||||||
|
}
|
||||||
parts = append(parts, part+")")
|
parts = append(parts, part+")")
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s on %s waits for the operator: %s", module, machine, strings.Join(parts, "; "))
|
return fmt.Sprintf("%s on %s waits for the operator: %s", module, machine, strings.Join(parts, "; "))
|
||||||
@@ -172,7 +240,7 @@ func needsOperatorObservation(module, node string, waits []inventory.Wait, rs []
|
|||||||
func needsOperatorWords(module, node string, waits []inventory.Wait) words {
|
func needsOperatorWords(module, node string, waits []inventory.Wait) words {
|
||||||
var acts []string
|
var acts []string
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
secret := false
|
secret, provision := false, false
|
||||||
for _, w := range waits {
|
for _, w := range waits {
|
||||||
var act string
|
var act string
|
||||||
switch {
|
switch {
|
||||||
@@ -180,6 +248,9 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words {
|
|||||||
act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true
|
act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true
|
||||||
case w.Setting != "":
|
case w.Setting != "":
|
||||||
act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node)
|
act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node)
|
||||||
|
case w.Provision != "":
|
||||||
|
// ADR 0337 §7: "give <what> of <provider> on <machine>"; the line is in the summary.
|
||||||
|
act, provision = "give "+w.What, true
|
||||||
}
|
}
|
||||||
if act != "" && !seen[act] {
|
if act != "" && !seen[act] {
|
||||||
seen[act] = true
|
seen[act] = true
|
||||||
@@ -197,6 +268,10 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words {
|
|||||||
explanation += " A hidden prompt opens at the desk when the secret is asked for, and what you type there " +
|
explanation += " A hidden prompt opens at the desk when the secret is asked for, and what you type there " +
|
||||||
"is sealed to the machine."
|
"is sealed to the machine."
|
||||||
}
|
}
|
||||||
|
if provision {
|
||||||
|
explanation += " A key only you can give is given once for the app that issued it, and reaches every " +
|
||||||
|
"module that uses it."
|
||||||
|
}
|
||||||
explanation += " Its update is in place and nothing was undone; it carries on by itself once it is given."
|
explanation += " Its update is in place and nothing was undone; it carries on by itself once it is given."
|
||||||
return words{
|
return words{
|
||||||
Headline: fmt.Sprintf("%s waits for you on %s", module, node),
|
Headline: fmt.Sprintf("%s waits for you on %s", module, node),
|
||||||
@@ -217,6 +292,12 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
|
|||||||
if f.given == nil {
|
if f.given == nil {
|
||||||
f.given = map[string]map[string]time.Time{}
|
f.given = map[string]map[string]time.Time{}
|
||||||
}
|
}
|
||||||
|
if f.outside == nil {
|
||||||
|
f.outside = map[string]outsideProvider{}
|
||||||
|
}
|
||||||
|
if f.pairsGiven == nil {
|
||||||
|
f.pairsGiven = map[string]map[string]bool{}
|
||||||
|
}
|
||||||
if inv == nil {
|
if inv == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -238,13 +319,57 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, read := f.given[module+"@"+machine]; read {
|
if _, read := f.given[module+"@"+machine]; !read {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if given, err := inv.GivenOwnSecrets(ctx, machine, module); err == nil {
|
if given, err := inv.GivenOwnSecrets(ctx, machine, module); err == nil {
|
||||||
f.given[module+"@"+machine] = given
|
f.given[module+"@"+machine] = given
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, read := f.pairsGiven[module+"@"+machine]; !read {
|
||||||
|
if given, err := inv.GivenPairsOf(ctx, machine, module); err == nil {
|
||||||
|
f.pairsGiven[module+"@"+machine] = given
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The provisions it requires whose credential only a person gives, and where their provider runs (ADR 0337).
|
||||||
|
m, known := f.manifests[module]
|
||||||
|
if !known {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, provision := range m.Requires {
|
||||||
|
if _, read := f.outside[provision]; read {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if shelf == nil {
|
||||||
|
var err error
|
||||||
|
if shelf, err = inv.Catalogue(ctx); err != nil {
|
||||||
|
shelf = map[string]catalogue.Manifest{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
provider, outside := catalogue.OffersIssuedOutside(shelf, provision)
|
||||||
|
if !outside {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.outside[provision] = outsideProvider{Module: provider, Machines: machinesRunning(ctx, inv, provider)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// machinesRunning is every machine a module is assigned to, by name; a read that fails is left out.
|
||||||
|
func machinesRunning(ctx context.Context, inv *inventory.Inventory, module string) []string {
|
||||||
|
nodes, err := inv.Nodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Only the summary's accept line reads it, which then names the provider's machine "<its machine>".
|
||||||
|
// empty-on-error: no wait is excused or refused by it
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, n := range nodes {
|
||||||
|
assigned, err := inv.Assigned(ctx, n.Name)
|
||||||
|
if err == nil && slices.Contains(assigned, module) {
|
||||||
|
out = append(out, n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitingModules is every module with a waiting resource in a statement.
|
// waitingModules is every module with a waiting resource in a statement.
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ func TestAWaitIsExcusedOnlyWhenItChecksOut(t *testing.T) {
|
|||||||
{"a secret the mesh makes", inventory.Wait{Part: "p", Secret: "made", What: "w"}, factsGiven(nil), "mesh makes itself"},
|
{"a secret the mesh makes", inventory.Wait{Part: "p", Secret: "made", What: "w"}, factsGiven(nil), "mesh makes itself"},
|
||||||
{"the bus account", inventory.Wait{Part: "p", Secret: "broker", What: "w"}, factsGiven(nil), "mesh makes itself"},
|
{"the bus account", inventory.Wait{Part: "p", Secret: "broker", What: "w"}, factsGiven(nil), "mesh makes itself"},
|
||||||
{"a setting not declared", inventory.Wait{Part: "p", Setting: "logins", What: "w"}, factsGiven(nil), "does not declare"},
|
{"a setting not declared", inventory.Wait{Part: "p", Setting: "logins", What: "w"}, factsGiven(nil), "does not declare"},
|
||||||
{"both", inventory.Wait{Part: "p", Secret: "licence", Setting: "smb-users", What: "w"}, factsGiven(nil), "one secret or one setting"},
|
{"both", inventory.Wait{Part: "p", Secret: "licence", Setting: "smb-users", What: "w"}, factsGiven(nil), "one secret, one setting or one provision"},
|
||||||
{"neither", inventory.Wait{Part: "p", What: "w"}, factsGiven(nil), "one secret or one setting"},
|
{"neither", inventory.Wait{Part: "p", What: "w"}, factsGiven(nil), "one secret, one setting or one provision"},
|
||||||
{"no manifest known", passwordWait, operatorWaitFacts{}, "no manifest"},
|
{"no manifest known", passwordWait, operatorWaitFacts{}, "no manifest"},
|
||||||
{"what was given cannot be read", passwordWait,
|
{"what was given cannot be read", passwordWait,
|
||||||
operatorWaitFacts{manifests: map[string]catalogue.Manifest{"mounts": mountsManifest}}, "could not be read"},
|
operatorWaitFacts{manifests: map[string]catalogue.Manifest{"mounts": mountsManifest}}, "could not be read"},
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One accept for a provider reaches every consumer that binds the provision from it, and each consumer's send
|
||||||
|
// places it; the per-pair form naming a consumer is refused for such a provision, naming the provider's form; a
|
||||||
|
// value the mesh made before the rule is not placed (novox/hq ADR 0337 §2, §3, §4, issue 164, issue 165). Needs the
|
||||||
|
// store (MESH_TEST_POSTGRES), and is named in testdata/store-tests.
|
||||||
|
func TestOneAcceptForAProviderIsPlacedForEveryConsumer(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
// Before the rule: tv's offer said nothing of its credential, and the send made one per pair.
|
||||||
|
register(t, open, catalogue.Manifest{Module: "tv", Version: "1",
|
||||||
|
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh}}})
|
||||||
|
for _, m := range []string{"requests", "indexer"} {
|
||||||
|
register(t, open, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"tv-api"},
|
||||||
|
Secrets: map[string]string{"tv-api": "/var/lib/mesh/" + m + "/tv-api.secret"}})
|
||||||
|
}
|
||||||
|
for _, a := range [][2]string{{"anchor", "tv"}, {"laptop", "requests"}, {"laptop", "indexer"}} {
|
||||||
|
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placed := func(module string) bool {
|
||||||
|
t.Helper()
|
||||||
|
plan, settings, err := planFor(ctx, open, "laptop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("laptop was refused: %v", err)
|
||||||
|
}
|
||||||
|
declared, err := declarationFor(ctx, open, "laptop", plan, settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("laptop did not compose: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range declared.Resources {
|
||||||
|
if r["path"] == "/var/lib/mesh/"+module+"/tv-api.secret" && r["sealed"] != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !placed("requests") || !placed("indexer") {
|
||||||
|
t.Fatal("before the rule, the send made and placed a credential per pair")
|
||||||
|
}
|
||||||
|
// The offer now says only a person gives it: the values the mesh made are not placed.
|
||||||
|
register(t, open, catalogue.Manifest{Module: "tv", Version: "2",
|
||||||
|
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh,
|
||||||
|
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}})
|
||||||
|
if placed("requests") || placed("indexer") {
|
||||||
|
t.Fatal("a credential nobody gave was placed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The per-pair form is refused, naming the provider's form, and reads nothing.
|
||||||
|
file := filepath.Join(t.TempDir(), "key")
|
||||||
|
if err := os.WriteFile(file, []byte("the app's key\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := secretCommand(ctx, []string{"accept", "laptop", "requests", "tv-api", "--provider", "anchor", "--from", file})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "secret accept anchor tv tv-api") {
|
||||||
|
t.Fatalf("the per-pair form was not refused naming the provider's: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
consumers, unresolved, err := consumersFrom(ctx, open, "anchor", "tv", "tv-api")
|
||||||
|
if err != nil || len(unresolved) != 0 {
|
||||||
|
t.Fatalf("the consumers of tv on anchor could not be found: %v %v", err, unresolved)
|
||||||
|
}
|
||||||
|
if len(consumers) != 2 {
|
||||||
|
t.Fatalf("the consumers of tv on anchor are %+v; want requests and indexer on laptop", consumers)
|
||||||
|
}
|
||||||
|
if _, err := open.inventory.AcceptSecretForProvision(ctx, "tv-api", "anchor", "tv", "the app's key", consumers); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !placed("requests") || !placed("indexer") {
|
||||||
|
t.Fatal("the value given once for the provider was not placed for every consumer")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A consumer may wait for a provision's credential only a person gives (novox/hq ADR 0337 §5–§7, issue 164).
|
||||||
|
|
||||||
|
var requestsManifest = catalogue.Manifest{Module: "requests", Version: "1", Requires: []string{"tv-api", "db"},
|
||||||
|
Secrets: map[string]string{"tv-api": "/s/tv-api", "db": "/s/db"}}
|
||||||
|
|
||||||
|
var tvKeyWait = inventory.Wait{Part: "the tv library", Provision: "tv-api", What: "the API key of tv"}
|
||||||
|
|
||||||
|
func outsideFacts(given map[string]bool) operatorWaitFacts {
|
||||||
|
return operatorWaitFacts{manifests: map[string]catalogue.Manifest{"requests": requestsManifest},
|
||||||
|
outside: map[string]outsideProvider{"tv-api": {Module: "tv", Machines: []string{"media"}}},
|
||||||
|
pairsGiven: map[string]map[string]bool{"requests@laptop": given}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §6: excused only when the module requires the provision, its provider says it is issued outside, and no value a
|
||||||
|
// person gave is held for the pair under the keys both ends have now.
|
||||||
|
func TestAWaitForAProvisionIsExcusedOnlyWhenTheStoreBearsItOut(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
w inventory.Wait
|
||||||
|
f operatorWaitFacts
|
||||||
|
says string
|
||||||
|
}{
|
||||||
|
{"required, issued outside, not given", tvKeyWait, outsideFacts(nil), ""},
|
||||||
|
{"given", tvKeyWait, outsideFacts(map[string]bool{"tv-api": true}), "was given"},
|
||||||
|
{"not required", inventory.Wait{Part: "p", Provision: "music-api", What: "w"}, outsideFacts(nil), "does not require"},
|
||||||
|
{"a credential the mesh gives itself", inventory.Wait{Part: "p", Provision: "db", What: "w"}, outsideFacts(nil),
|
||||||
|
"mesh gives itself"},
|
||||||
|
{"what was given cannot be read", tvKeyWait, operatorWaitFacts{
|
||||||
|
manifests: map[string]catalogue.Manifest{"requests": requestsManifest},
|
||||||
|
outside: map[string]outsideProvider{"tv-api": {Module: "tv"}}}, "could not be read"},
|
||||||
|
{"a provision and a secret", inventory.Wait{Part: "p", Provision: "tv-api", Secret: "x", What: "w"},
|
||||||
|
outsideFacts(nil), "one secret, one setting or one provision"},
|
||||||
|
} {
|
||||||
|
err := checkWait("requests", "laptop", c.w, c.f)
|
||||||
|
switch {
|
||||||
|
case c.says == "" && err != nil:
|
||||||
|
t.Errorf("%s: refused: %v", c.name, err)
|
||||||
|
case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)):
|
||||||
|
t.Errorf("%s: %v; want a refusal saying %q", c.name, err, c.says)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §7: an excused wait is the consumer's needs-operator condition, in plain words, its summary naming the one line
|
||||||
|
// that gives the credential for every consumer of the provider; the wait as kept is not changed.
|
||||||
|
func TestAWaitForAProvisionNamesTheOneAcceptForItsProvider(t *testing.T) {
|
||||||
|
r := inventory.ResourceHealth{Module: "requests", Resource: "requests.app", Kind: "container", Target: "requests",
|
||||||
|
State: link.StateWaiting, Check: "tool", Waits: []inventory.Wait{tvKeyWait}}
|
||||||
|
kept := []inventory.ResourceHealth{r}
|
||||||
|
got := checkWaiting("laptop", kept, outsideFacts(nil))
|
||||||
|
if got[0].State != link.StateWaiting {
|
||||||
|
t.Fatalf("an excused wait for a provision was judged: %+v", got[0])
|
||||||
|
}
|
||||||
|
if kept[0].Waits[0].Act != "" {
|
||||||
|
t.Fatal("checking the wait changed the statement as kept")
|
||||||
|
}
|
||||||
|
waits, waiting := operatorWait("requests", got)
|
||||||
|
if !waiting {
|
||||||
|
t.Fatal("the module does not read as waiting for the operator")
|
||||||
|
}
|
||||||
|
o := needsOperatorObservation("requests", "laptop", waits, got)
|
||||||
|
if !strings.Contains(o.Summary, "secret accept media tv tv-api") {
|
||||||
|
t.Fatalf("the summary does not name the accept for the provider: %q", o.Summary)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(o.Needs, "give the API key of tv") {
|
||||||
|
t.Fatalf("the needs do not name the act: %q", o.Needs)
|
||||||
|
}
|
||||||
|
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
|
||||||
|
Resolved: o.Resolved, Needs: o.Needs, Actions: o.Actions}, "laptop"); !ok {
|
||||||
|
t.Fatalf("the condition is not in plain words: %s", why)
|
||||||
|
}
|
||||||
|
// A provider on several machines, or none: the line names its machine as a place to fill.
|
||||||
|
if line := (outsideProvider{Module: "tv", Machines: []string{"a", "b"}}).acceptLine("tv-api"); line !=
|
||||||
|
"secret accept <its machine> tv tv-api" {
|
||||||
|
t.Fatalf("a provider on two machines reads %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §3: the consumers one accept reaches are those whose resolution answers the provision from that provider.
|
||||||
|
func TestTheConsumersOfAProviderAreThoseItAnswers(t *testing.T) {
|
||||||
|
plan := catalogue.Resolution{Needs: []catalogue.Needed{
|
||||||
|
{Name: "tv-api", From: "media", Module: "tv", For: "requests"},
|
||||||
|
{Name: "tv-api", From: "media", Module: "tv", For: "indexer", Local: "main"},
|
||||||
|
{Name: "tv-api", From: "other", Module: "tv", For: "elsewhere"},
|
||||||
|
{Name: "tv-api", From: "media", Module: "another-tv", For: "not-this-module"},
|
||||||
|
{Name: "db", From: "media", Module: "tv", For: "requests"},
|
||||||
|
{Name: "tv-api", ByRecord: true, For: "by-record"},
|
||||||
|
}}
|
||||||
|
got := bindsFrom(plan, "laptop", "media", "tv", "tv-api")
|
||||||
|
if len(got) != 2 || got[0].ConsumerModule != "indexer" || got[0].Local != "main" || got[1].ConsumerModule != "requests" {
|
||||||
|
t.Fatalf("the consumers from tv on media are %+v", got)
|
||||||
|
}
|
||||||
|
said := acceptedSaid("media", "tv", "tv-api", inventory.ProvisionAccepted{
|
||||||
|
Sealed: []inventory.PairEnd{{Consumer: "laptop", ConsumerModule: "requests"}},
|
||||||
|
Unsealable: []inventory.PairEnd{{Consumer: "new", ConsumerModule: "indexer"}}}, []string{"down: refused"})
|
||||||
|
for _, want := range []string{"sealed to requests on laptop", "no sealing key", "down: refused"} {
|
||||||
|
if !strings.Contains(said, want) {
|
||||||
|
t.Errorf("what the accept says lacks %q:\n%s", want, said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **Placement leaves the settings layers and no machine is told anything new** (novox/hq ADR 0348 clause
|
||||||
|
// 11, issue 651). A mesh is seeded as the store holds it before migration 0097 — `places`, `accesses` and a
|
||||||
|
// kept network among the settings, at the mesh's level and a machine's, one layer of placement only — then
|
||||||
|
// migrated, and every machine's declaration is composed. What each machine is told of the seeded modules is
|
||||||
|
// byte for byte what the controller before this change composed from the same seed: the golden files were
|
||||||
|
// written by the integration branch's controller at bfc79c6 running this same test (MESH_WRITE_GOLDEN=1),
|
||||||
|
// where there was no migration 0097 to run.
|
||||||
|
func TestADeclarationIsTheSameAfterPlacementMoves(t *testing.T) {
|
||||||
|
open, migrate := aMeshBefore(t, 97)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := open.inventory
|
||||||
|
image := "registry.invalid:5000/library@sha256:" + strings.Repeat("a", 64)
|
||||||
|
register(t, open, catalogue.Manifest{Module: "library", Version: "1",
|
||||||
|
Accesses: []catalogue.Access{{ID: "series"}, {ID: "downloads", Mode: catalogue.AccessRead}},
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "config", "type": "directory", "mode": "0750"},
|
||||||
|
{"id": "server", "type": "container", "image": image,
|
||||||
|
"volumes": []any{"${dir:config}:/config", "${access:series}:/series", "${access:downloads}:/downloads:ro"}},
|
||||||
|
// Today's form: a mergeable file naming no keys takes every key of the layer (ADR 0326 §3).
|
||||||
|
{"id": "runtime-config", "type": "file", "path": "${dir:config}/runtime.json", "mode": "0640",
|
||||||
|
"content": `{"log":"info"}`, "merge": "json"},
|
||||||
|
}})
|
||||||
|
register(t, open, catalogue.Manifest{Module: "found", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "server", "type": "container", "image": image}}})
|
||||||
|
if err := inv.SetAdopted(ctx, "anchor", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, l := range []struct{ node, module, values string }{
|
||||||
|
{"", "library", `{"accesses": {"downloads": "/storage/downloads"}, "log": "warn"}`},
|
||||||
|
{"anchor", "library", `{"places": {"config": {"path": "/var/lib/library/config", "owner": "1001:2000"}},
|
||||||
|
"accesses": {"series": "/storage/media/series"}, "workers": 4}`},
|
||||||
|
{"laptop", "library", `{"accesses": {"series": "/home/media/series"}}`},
|
||||||
|
{"anchor", "found", `{"networks": {"server": ["predecessor_default"]}}`},
|
||||||
|
} {
|
||||||
|
if l.node == "" {
|
||||||
|
inv.ExecForTest(t, `insert into settings (node, module, values) values (null, $1, $2)`, l.module, l.values)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inv.ExecForTest(t, `insert into settings (node, module, values) select id, $2, $3 from node where name = $1`,
|
||||||
|
l.node, l.module, l.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate()
|
||||||
|
// Assigned once the store is migrated: an assignment reads the layers, as this controller holds them.
|
||||||
|
for _, a := range [][]string{{"anchor", "library", "found"}, {"laptop", "library"}} {
|
||||||
|
if _, err := assign(ctx, open, a[0], a[1:]...); err != nil {
|
||||||
|
t.Fatalf("assign %v: %v", a, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gens, err := generators(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, node := range []string{"anchor", "laptop"} {
|
||||||
|
plan, settings, err := planFor(ctx, open, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Allocating)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
composed, err := plan.Compose(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(composed.LeftOut) > 0 {
|
||||||
|
t.Fatalf("%s leaves out %v", node, composed.LeftOut)
|
||||||
|
}
|
||||||
|
var told []map[string]any
|
||||||
|
for _, r := range composed.Resources {
|
||||||
|
if owner := composed.Owner[r["id"].(string)]; owner == "library" || owner == "found" {
|
||||||
|
told = append(told, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(told, func(i, j int) bool { return told[i]["id"].(string) < told[j]["id"].(string) })
|
||||||
|
got, err := json.MarshalIndent(told, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
golden := filepath.Join("testdata", "placement-moved", node+".json")
|
||||||
|
if os.Getenv("MESH_WRITE_GOLDEN") != "" {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(golden, append(got, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want, err := os.ReadFile(golden)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(want) != string(got)+"\n" {
|
||||||
|
t.Errorf("%s is told something else after placement moved:\n%s\nwas:\n%s", node, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -456,6 +456,9 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
|||||||
"release-held": worded(func(o conditions.Observation) words {
|
"release-held": worded(func(o conditions.Observation) words {
|
||||||
return releaseHeldWords(nil, o.Also)
|
return releaseHeldWords(nil, o.Also)
|
||||||
}),
|
}),
|
||||||
|
"release-silent": worded(func(o conditions.Observation) words {
|
||||||
|
return releaseSilentWords(o.Also)
|
||||||
|
}),
|
||||||
"facts-stale": worded(func(o conditions.Observation) words {
|
"facts-stale": worded(func(o conditions.Observation) words {
|
||||||
return words{Headline: "Merge checks use outdated facts",
|
return words{Headline: "Merge checks use outdated facts",
|
||||||
Explanation: "The facts a merge check judges a change against are out of date; they are gathered again " +
|
Explanation: "The facts a merge check judges a change against are out of date; they are gathered again " +
|
||||||
@@ -841,6 +844,19 @@ func releaseHeldWords(modules, machines []string) words {
|
|||||||
Resolved: "Resolved: the held updates are released"}
|
Resolved: "Resolved: the held updates are released"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// releaseSilentWords are the plain words of machines that said nothing on an update they were sent (ADR 0353 §4).
|
||||||
|
func releaseSilentWords(machines []string) words {
|
||||||
|
where := "Some machines"
|
||||||
|
if len(machines) > 0 {
|
||||||
|
where = conditions.Capital(namesWords(machines, 4))
|
||||||
|
}
|
||||||
|
return words{Headline: "Machines did not answer an update",
|
||||||
|
Needs: "check that they are awake and on the mesh; the next walk sends them what waits.",
|
||||||
|
Explanation: where + " were sent module updates and said nothing about them within fifteen minutes, so " +
|
||||||
|
"they were left as they were. What they run is not known to be the update.",
|
||||||
|
Resolved: "Resolved: the next walk reached them"}
|
||||||
|
}
|
||||||
|
|
||||||
// reloginNeeds is what an account waiting for its groups needs (ADR 0252).
|
// reloginNeeds is what an account waiting for its groups needs (ADR 0252).
|
||||||
func reloginNeeds(node string) string {
|
func reloginNeeds(node string) string {
|
||||||
return fmt.Sprintf("log out of %s completely and log in again, or restart it.", node)
|
return fmt.Sprintf("log out of %s completely and log in again, or restart it.", node)
|
||||||
|
|||||||
+113
-9
@@ -182,12 +182,21 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
}
|
}
|
||||||
var secret inventory.Secret
|
var secret inventory.Secret
|
||||||
var err error
|
var err error
|
||||||
if n.SharedOwn != "" {
|
if outsideModule, outside := issuedOutsideBy(shelf, n); outside {
|
||||||
|
// Only a person can give it (novox/hq ADR 0337, issue 164): read as given, never made.
|
||||||
|
secret, err = inv.GivenPairSecret(ctx, n.Name, nodeName, n.For, n.From, outsideModule, n.Local)
|
||||||
|
} else if n.SharedOwn != "" {
|
||||||
// The provider's one credential, sealed to this consumer too (novox/hq ADR 0158).
|
// The provider's one credential, sealed to this consumer too (novox/hq ADR 0158).
|
||||||
secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn)
|
secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn)
|
||||||
} else {
|
} else {
|
||||||
secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
|
secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
|
||||||
}
|
}
|
||||||
|
if inventory.IsNotGiven(err) {
|
||||||
|
// **Not given, and the rest of the consumer runs** (ADR 0337 §4): no credential file, so the module can
|
||||||
|
// tell it was not given and says it waits for it; the machine is not refused for what only a person can
|
||||||
|
// give, and nothing the mesh made stands in for it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Said rather than skipped. A machine that resolves cleanly and receives no
|
// Said rather than skipped. A machine that resolves cleanly and receives no
|
||||||
// credential is one that will fail to authenticate at some later, less obvious
|
// credential is one that will fail to authenticate at some later, less obvious
|
||||||
@@ -280,9 +289,14 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
caps, _ := inv.ProfileOf(ctx, n.Name)
|
caps, _ := inv.ProfileOf(ctx, n.Name)
|
||||||
|
// Its public domain too (novox/hq ADR 0346): a provider elsewhere serves its own composed names,
|
||||||
|
// and composes them as its own plan does, under its own domain.
|
||||||
|
// Read as its profile is: a machine whose domain cannot be read (removed since the list was read)
|
||||||
|
// composes no public name, which leaves the key out and says so, and fails no other machine's plan.
|
||||||
|
domain, _ := inv.PublicDomainOf(ctx, n.Name)
|
||||||
others = append(others, candidate{
|
others = append(others, candidate{
|
||||||
catalogue.Node{Name: n.Name, Site: siteOf[n.Name], Capabilities: caps,
|
catalogue.Node{Name: n.Name, Site: siteOf[n.Name], Capabilities: caps,
|
||||||
At: onNetwork[n.Name]}, theirs})
|
At: onNetwork[n.Name], PublicDomain: domain}, theirs})
|
||||||
}
|
}
|
||||||
|
|
||||||
offered := map[string][]catalogue.Provider{}
|
offered := map[string][]catalogue.Provider{}
|
||||||
@@ -301,12 +315,18 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
// What that module says a consumer needs to know, with that node's settings on
|
// What that module says a consumer needs to know, with that node's settings on
|
||||||
// it: a port somebody moved on the provider is a port its consumers must be told
|
// it: a port somebody moved on the provider is a port its consumers must be told
|
||||||
// about, and the two coming from different places is how they come to disagree.
|
// about, and the two coming from different places is how they come to disagree.
|
||||||
serves, err := servedOnNode(ctx, inv, o.node.Name, m, name)
|
serves, layers, err := servedOnNode(ctx, inv, o.node.Name, m, name)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.World{}, err
|
||||||
|
}
|
||||||
|
// And its own composed names, as that node composes them (novox/hq ADR 0346): a name it
|
||||||
|
// does not compose leaves the key out, said under each consumer's need.
|
||||||
|
serves, leftOut, err := got.ServedOwnNames(m, name, serves, catalogue.WithDefaults(m, layers))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.World{}, err
|
return catalogue.World{}, err
|
||||||
}
|
}
|
||||||
offered[name] = append(offered[name], catalogue.Provider{
|
offered[name] = append(offered[name], catalogue.Provider{
|
||||||
Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module})
|
Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module, LeftOut: leftOut})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,6 +462,12 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
}
|
}
|
||||||
|
// Which ports the filter admits from the local network, and from which networks, for plan to say
|
||||||
|
// (novox/hq ADR 0340 §8). The same rules the composition rendered, never sent.
|
||||||
|
rules, err := plan.Rules(with)
|
||||||
|
if err != nil {
|
||||||
|
return sendable{}, err
|
||||||
|
}
|
||||||
// And what was taken on it, said in every declaration it is sent from this one place.
|
// And what was taken on it, said in every declaration it is sent from this one place.
|
||||||
adoption, err := adoptionOf(ctx, open.inventory, record, plan, composed)
|
adoption, err := adoptionOf(ctx, open.inventory, record, plan, composed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -451,7 +477,8 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
|||||||
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
|
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
|
||||||
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld,
|
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld,
|
||||||
unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced,
|
unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced,
|
||||||
modules: namedModules(plan, composed.LeftOut)}
|
modules: namedModules(plan, composed.LeftOut),
|
||||||
|
localNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks)}
|
||||||
// And which build of each module it carries, for the send to record (novox/hq issue 259, ADR
|
// And which build of each module it carries, for the send to record (novox/hq issue 259, ADR
|
||||||
// 0221). Read only on the send path: a question about what would be sent records nothing.
|
// 0221). Read only on the send path: a question about what would be sent records nothing.
|
||||||
if choosing == Allocating {
|
if choosing == Allocating {
|
||||||
@@ -960,6 +987,12 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
}
|
}
|
||||||
|
// And which of the networks it is attached to count as its local network (novox/hq ADR 0340):
|
||||||
|
// private ranges only, on a link facing outside it. Reported by the machine, never set.
|
||||||
|
local, err := localNetworksOf(ctx, inv, node)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
// Where this machine reaches each mesh seat's holder, for ${seat:<seat>:reach} (novox/hq ADR
|
// Where this machine reaches each mesh seat's holder, for ${seat:<seat>:reach} (novox/hq ADR
|
||||||
// 0222): the artifact store as this network reaches it, which the container runtime is told to
|
// 0222): the artifact store as this network reaches it, which the container runtime is told to
|
||||||
// trust (ADR 0082). The same address composed into every reference the mesh built.
|
// trust (ADR 0082). The same address composed into every reference the mesh built.
|
||||||
@@ -975,15 +1008,21 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
}
|
}
|
||||||
|
readsBusy, err := engineReadsBusy(ctx, inv, node)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
return catalogue.Rendering{
|
return catalogue.Rendering{
|
||||||
ReadsHealth: readsHealth,
|
ReadsHealth: readsHealth,
|
||||||
JudgesRoot: judgesRoot,
|
JudgesRoot: judgesRoot,
|
||||||
|
ReadsBusy: readsBusy,
|
||||||
BusMembership: memberships[node],
|
BusMembership: memberships[node],
|
||||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
||||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||||
Machines: machines, Zones: zones, Holders: replicas,
|
Machines: machines, Zones: zones, Holders: replicas,
|
||||||
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
||||||
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
|
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
|
||||||
|
LocalNetworks: catalogue.CountedLocalNetworks(local),
|
||||||
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built,
|
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built,
|
||||||
BusUsers: busUsers, Withheld: withheld, Unbound: unbound,
|
BusUsers: busUsers, Withheld: withheld, Unbound: unbound,
|
||||||
}, record, nil
|
}, record, nil
|
||||||
@@ -1010,6 +1049,35 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string
|
|||||||
return had && stated.Contract >= link.RootContract, nil
|
return had && stated.Contract >= link.RootContract, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// engineReadsBusy says whether a machine's node-engine reads a container's `busy` (novox/hq ADR 0335), by its
|
||||||
|
// own newest statement, for the same reason as engineReadsHealth: an older engine parses strictly.
|
||||||
|
func engineReadsBusy(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
|
||||||
|
stated, had, err := inv.HealthOf(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return had && stated.Contract >= link.BusyContract, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// busyNotSent is, for a node-engine that does not read a container's busy, each container whose module declares
|
||||||
|
// one: not sent it, so replaced at once while it may hold work (novox/hq ADR 0335). Nothing for one that reads it.
|
||||||
|
func busyNotSent(modules []catalogue.Manifest, reads bool) []string {
|
||||||
|
if reads {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, m := range modules {
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if _, has := r[catalogue.BusyField]; has {
|
||||||
|
out = append(out, fmt.Sprintf("%s.%v declares busy, and this node-engine does not read it yet (its "+
|
||||||
|
"statement's contract is below %d): not sent, so the container is replaced at once, busy or not "+
|
||||||
|
"(novox/hq ADR 0335)", m.Module, r["id"], link.BusyContract))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
|
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
|
||||||
// 0199): the zone settled from that node's settings, the node's private address, the port the
|
// 0199): the zone settled from that node's settings, the node's private address, the port the
|
||||||
// answering listen is published on there.
|
// answering listen is published on there.
|
||||||
@@ -1403,10 +1471,29 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
// in this output would have told anybody that.
|
// in this output would have told anybody that.
|
||||||
for _, n := range plan.Needs {
|
for _, n := range plan.Needs {
|
||||||
fmt.Printf(" needs %s from %s, for %s\n", n.Name, n.From, n.For)
|
fmt.Printf(" needs %s from %s, for %s\n", n.Name, n.From, n.For)
|
||||||
|
// And a served key the provider left out, because its machine composes no name it asks for
|
||||||
|
// (novox/hq ADR 0346 §3).
|
||||||
|
for _, line := range n.LeftOut {
|
||||||
|
fmt.Printf(" %s\n", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What the providers on this machine leave out of what they serve, and why: said where the remedy
|
||||||
|
// is (novox/hq ADR 0346 §3).
|
||||||
|
for _, line := range plan.ServedLeftOut(settings) {
|
||||||
|
fmt.Printf("%s: %s\n", args[0], line)
|
||||||
}
|
}
|
||||||
for _, line := range unboundLines(plan) {
|
for _, line := range unboundLines(plan) {
|
||||||
fmt.Println(line)
|
fmt.Println(line)
|
||||||
}
|
}
|
||||||
|
// And a container's busy that this machine's node-engine is too old to be sent (novox/hq ADR 0335): its
|
||||||
|
// container is replaced at once, as before. A statement that cannot be read is said as such.
|
||||||
|
if reads, err := engineReadsBusy(ctx, open.inventory, args[0]); err != nil {
|
||||||
|
fmt.Printf("%s: whether its node-engine reads a container's busy could not be read: %v\n", args[0], err)
|
||||||
|
} else {
|
||||||
|
for _, line := range busyNotSent(plan.Modules, reads) {
|
||||||
|
fmt.Printf("%s: %s\n", args[0], line)
|
||||||
|
}
|
||||||
|
}
|
||||||
declared, err := declarationFor(ctx, open, args[0], plan, settings)
|
declared, err := declarationFor(ctx, open, args[0], plan, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1417,6 +1504,11 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Printf(" %-20s settings from %s\n", module, layer.From)
|
fmt.Printf(" %-20s settings from %s\n", module, layer.From)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// What each port reached from the local network resolved to on this machine (novox/hq ADR 0340 §8),
|
||||||
|
// or that it resolved to none.
|
||||||
|
for _, p := range declared.localNetwork {
|
||||||
|
fmt.Println(" " + p.Said())
|
||||||
|
}
|
||||||
fmt.Printf("\n%d resource(s)\n", len(resources))
|
fmt.Printf("\n%d resource(s)\n", len(resources))
|
||||||
|
|
||||||
if *show {
|
if *show {
|
||||||
@@ -1538,19 +1630,20 @@ func managerPublicKeyFor(
|
|||||||
// artifact store's trust and the references composed through it (04-ISSUES/102). Unreadable
|
// artifact store's trust and the references composed through it (04-ISSUES/102). Unreadable
|
||||||
// given ports are that node's refusal to report, not this reader's.
|
// given ports are that node's refusal to report, not this reader's.
|
||||||
func servedOnNode(ctx context.Context, inv *inventory.Inventory, node string,
|
func servedOnNode(ctx context.Context, inv *inventory.Inventory, node string,
|
||||||
m catalogue.Manifest, provision string) (map[string]any, error) {
|
m catalogue.Manifest, provision string) (map[string]any, []catalogue.Layer, error) {
|
||||||
ports, layers, err := portsGivenOn(ctx, inv, node, m)
|
ports, layers, err := portsGivenOn(ctx, inv, node, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
serves := catalogue.ServedOn(m, provision, ports)
|
serves := catalogue.ServedOn(m, provision, ports)
|
||||||
if len(serves) > 0 {
|
if len(serves) > 0 {
|
||||||
serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers))
|
serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return serves, nil
|
// The layers too, read once, for what is filled after (novox/hq ADR 0346).
|
||||||
|
return serves, layers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the
|
// portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the
|
||||||
@@ -1742,6 +1835,17 @@ func busUserList(ctx context.Context, inv *inventory.Inventory,
|
|||||||
|
|
||||||
// providerModuleOf is which module answers a need on the providing node: the one in this node's
|
// providerModuleOf is which module answers a need on the providing node: the one in this node's
|
||||||
// own set when the provider is here, else the one the catalogue says offers it.
|
// own set when the provider is here, else the one the catalogue says offers it.
|
||||||
|
// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the
|
||||||
|
// mesh (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so.
|
||||||
|
func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) {
|
||||||
|
if n.Module != "" {
|
||||||
|
if m, known := shelf[n.Module]; known {
|
||||||
|
return n.Module, m.IssuedOutsideOf(n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return catalogue.OffersIssuedOutside(shelf, n.Name)
|
||||||
|
}
|
||||||
|
|
||||||
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
|
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
|
||||||
for _, m := range resolved.Modules {
|
for _, m := range resolved.Modules {
|
||||||
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The list of predefined keys the mesh publishes (novox/hq ADR 0348 §7, design 57 package K2, issue 661).
|
||||||
|
//
|
||||||
|
// **The newest trunk build of the catalogue wins.** A build of the catalogue reads predefined.json at the commit
|
||||||
|
// it builds and carries it with the commit's generation (link.PredefinedList). Registration keeps it in the
|
||||||
|
// controller's store when that generation is larger than the one kept (inventory.KeepPredefined), so an older
|
||||||
|
// build's list heard later never replaces a newer one, and every manifest the controller reads again is judged
|
||||||
|
// against the list kept (catalogue.UsePredefined). mesh-config publishes it on the bus from there (design 57
|
||||||
|
// package K3); the controller keeps no config value, only the keys.
|
||||||
|
|
||||||
|
// fromTheCatalogue is whether a build is of the catalogue's repository, whose list alone the mesh publishes: by
|
||||||
|
// its name, in the URL form or the seat form.
|
||||||
|
func fromTheCatalogue(result link.BuildResult) bool {
|
||||||
|
for _, repository := range []string{result.Repository, sourceRepository(result)} {
|
||||||
|
name := strings.TrimSuffix(path.Base(strings.TrimRight(repository, "/")), ".git")
|
||||||
|
if name == catalogue.CatalogueRepository {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceRepository(result link.BuildResult) string {
|
||||||
|
if result.Source == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return result.Source.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// carriedList is the list a build of the catalogue carried, read strictly; nil for a build of another
|
||||||
|
// repository, or one that carried none.
|
||||||
|
func carriedList(result link.BuildResult) (catalogue.PredefinedList, error) {
|
||||||
|
if result.Predefined == nil || !fromTheCatalogue(result) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
list, err := catalogue.ParsePredefined(result.Predefined.Keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s carried a list of predefined keys that is refused: %w", result.ID, err)
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgedWith is the list a build's manifest is first judged against at registration: the catalogue's own at the
|
||||||
|
// commit built, which its build judged it against, where it carried one; the list kept otherwise.
|
||||||
|
func judgedWith(result link.BuildResult) (catalogue.PredefinedList, error) {
|
||||||
|
list, err := carriedList(result)
|
||||||
|
if err != nil || list != nil {
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
return catalogue.PublishedPredefined(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPredefined makes the list kept in the store the one this controller judges manifests against. A store
|
||||||
|
// keeping none leaves the list it has.
|
||||||
|
func loadPredefined(ctx context.Context, inv *inventory.Inventory) error {
|
||||||
|
kept, has, err := inv.PredefinedKept(ctx)
|
||||||
|
if err != nil || !has {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
catalogue.UsePredefined(kept.Keys)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishPredefined keeps a trunk build's list when it is newer than the one kept, says so, and loads what the
|
||||||
|
// store keeps then — this build's, or a newer one another controller process kept.
|
||||||
|
func publishPredefined(ctx context.Context, inv *inventory.Inventory, result link.BuildResult,
|
||||||
|
list catalogue.PredefinedList) error {
|
||||||
|
kept, err := inv.KeepPredefined(ctx, inventory.PredefinedRecord{Keys: list, Repository: result.Repository,
|
||||||
|
Commit: result.Commit, Generation: result.Predefined.Generation, Build: result.ID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if kept {
|
||||||
|
fmt.Printf("%s: the list of predefined keys at %s (generation %d) is published: %s\n", result.ID,
|
||||||
|
short(result.Commit), result.Predefined.Generation, strings.Join(list.Keys(), ", "))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s: the list of predefined keys at %s (generation %d) is not newer than the one kept, which "+
|
||||||
|
"stands\n", result.ID, short(result.Commit), result.Predefined.Generation)
|
||||||
|
}
|
||||||
|
return loadPredefined(ctx, inv)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The list of predefined keys (novox/hq ADR 0348 §7, design 57 package K2, issue 661): `module check` and the
|
||||||
|
// merge gate judge each manifest against the list at the root of its tree; registration publishes the newest
|
||||||
|
// trunk build's list of the catalogue and judges every module against the list the mesh publishes.
|
||||||
|
|
||||||
|
const theFirstKeys = `{
|
||||||
|
"font-size": {"type": "number", "description": "the size text is shown in, in points", "minimum": 6, "maximum": 72},
|
||||||
|
"agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// notifierFallingBackTo is the notifier in the config form, its font size falling back to the key named.
|
||||||
|
func notifierFallingBackTo(key, kind string) string {
|
||||||
|
def := "10"
|
||||||
|
if kind == "string" {
|
||||||
|
def = `"10"`
|
||||||
|
}
|
||||||
|
return `{"module":"notifier","version":"1",
|
||||||
|
"config":{"font-size":{"type":"` + kind + `","description":"the notification's font size, in points",
|
||||||
|
"default":` + def + `,"predefined":"` + key + `"}},
|
||||||
|
"resources":[{"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644",
|
||||||
|
"content":"font = ${config:font-size}\n"}]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutAPublishedList judges the test against no list the mesh published, and puts the process's back after.
|
||||||
|
func withoutAPublishedList(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
was := catalogue.ReplacePublished(nil)
|
||||||
|
t.Cleanup(func() { catalogue.ReplacePublished(was) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// aRepositoryTree is a repository's checkout holding one module at modules/notifier, and the list when given.
|
||||||
|
func aRepositoryTree(t *testing.T, manifest string, list *string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if list != nil {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, catalogue.PredefinedFile), []byte(*list), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aTemplatedModule(t, filepath.Join(root, "modules", "notifier"), manifest, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleCheckJudgesAManifestAgainstTheListAtItsRepositoryRoot(t *testing.T) {
|
||||||
|
withoutAPublishedList(t)
|
||||||
|
list := theFirstKeys
|
||||||
|
check := func(path string) (string, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := moduleCheck([]string{path}, &out)
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
if out, err := check(aRepositoryTree(t, notifierFallingBackTo("font-size", "number"), &list)); err != nil ||
|
||||||
|
!strings.Contains(out, "notifier: ok") {
|
||||||
|
t.Fatalf("a module naming font-size was refused: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
manifest string
|
||||||
|
list *string
|
||||||
|
says string
|
||||||
|
}{
|
||||||
|
{notifierFallingBackTo("font-scale", "number"), &list, `"font-scale", which is not on the list`},
|
||||||
|
{notifierFallingBackTo("font-size", "string"), &list, "which is a number, and this value is a string"},
|
||||||
|
{notifierFallingBackTo("font-size", "number"), nil, "is not present"},
|
||||||
|
{notifierFallingBackTo("font-size", "number"), ptr(`{"font-size": {"type": "number"}}`), "no description"},
|
||||||
|
} {
|
||||||
|
if out, err := check(aRepositoryTree(t, c.manifest, c.list)); err == nil || !strings.Contains(out, c.says) {
|
||||||
|
t.Errorf("wanted the check refused saying %q, got %v:\n%s", c.says, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr(s string) *string { return &s }
|
||||||
|
|
||||||
|
// writeTheList puts the list at the root of a tree the gate judges.
|
||||||
|
func writeTheList(t *testing.T, tree, list string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(tree, catalogue.PredefinedFile), []byte(list), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMergeGateJudgesEachManifestAgainstTheListOfTheTree(t *testing.T) {
|
||||||
|
withoutAPublishedList(t)
|
||||||
|
f, manifests := templatedMesh(t)
|
||||||
|
|
||||||
|
v := gateJudged(t, f, writeTheList(t, aTree(t, withEdit(manifests, "notifier",
|
||||||
|
notifierFallingBackTo("font-size", "number"))), theFirstKeys), "modules/notifier/module.json")
|
||||||
|
if v.Verdict == "fail" || v.Modules["notifier"] != "changed" {
|
||||||
|
t.Fatalf("a module naming font-size on the tree's list failed the gate (%v):\n%s", v.Modules, v.Report())
|
||||||
|
}
|
||||||
|
for _, c := range []struct{ manifest, list, says string }{
|
||||||
|
{notifierFallingBackTo("font-scale", "number"), theFirstKeys, `"font-scale", which is not on the list`},
|
||||||
|
{notifierFallingBackTo("font-size", "string"), theFirstKeys, "which is a number"},
|
||||||
|
{inlineNotifier, `{"font-size": {"type": "number", "description": "points", "unit": "pt"}}`, `unknown field "unit"`},
|
||||||
|
} {
|
||||||
|
v := gateJudged(t, f, writeTheList(t, aTree(t, withEdit(manifests, "notifier", c.manifest)), c.list),
|
||||||
|
"modules/notifier/module.json", catalogue.PredefinedFile)
|
||||||
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), c.says) {
|
||||||
|
t.Errorf("wanted the gate to fail saying %q:\n%s", c.says, v.Report())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogueBuild is a trunk build of the catalogue carrying the list at a generation.
|
||||||
|
func catalogueBuild(id, manifest, list string, generation int) link.BuildResult {
|
||||||
|
r := link.BuildResult{ID: id, Repository: "http://forge.internal:20000/novox/mesh-catalog.git",
|
||||||
|
Path: "modules/notifier", Ref: "main", On: "anchor", Commit: strings.Repeat(id[len(id)-1:], 12),
|
||||||
|
Manifest: json.RawMessage(manifest), Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
||||||
|
if list != "" {
|
||||||
|
r.Predefined = &link.PredefinedList{Keys: json.RawMessage(list), Generation: generation}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrationPublishesTheNewestTrunkListAndJudgesEveryModuleAgainstIt(t *testing.T) {
|
||||||
|
withoutAPublishedList(t)
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// A manifest naming font-size registers, and its build's list is published.
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory,
|
||||||
|
catalogueBuild("b-1", notifierFallingBackTo("font-size", "number"), theFirstKeys, 10))); err != nil {
|
||||||
|
t.Fatalf("a module naming font-size was not registered: %v", err)
|
||||||
|
}
|
||||||
|
kept, has, err := open.inventory.PredefinedKept(ctx)
|
||||||
|
if err != nil || !has || kept.Generation != 10 || kept.Build != "b-1" {
|
||||||
|
t.Fatalf("the list was not published: %+v %v %v", kept, has, err)
|
||||||
|
}
|
||||||
|
if got := catalogue.PublishedPredefined(); len(got) != 2 {
|
||||||
|
t.Fatalf("the controller judges against %v, not the list published", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One naming a key not on the list, or of another type, is refused and never enters the catalogue.
|
||||||
|
for i, c := range []struct{ manifest, says string }{
|
||||||
|
{notifierFallingBackTo("font-scale", "number"), "not on the list"},
|
||||||
|
{notifierFallingBackTo("font-size", "string"), "which is a number"},
|
||||||
|
} {
|
||||||
|
id := []string{"b-2", "b-3"}[i]
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, catalogueBuild(id, c.manifest, theFirstKeys, 10)); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("wanted the registration refused saying %q, got %v", c.says, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src, err := open.inventory.SourceOf(ctx, "notifier")
|
||||||
|
if err != nil || src.BuiltFrom != strings.Repeat("1", 12) {
|
||||||
|
t.Fatalf("a refused build replaced the registered one: %+v %v", src, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newer build's list, which drops agent-managed-settings, replaces the published one.
|
||||||
|
smaller := `{"font-size": {"type": "number", "description": "points", "minimum": 6, "maximum": 72}}`
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, catalogueBuild("b-4",
|
||||||
|
notifierFallingBackTo("font-size", "number"), smaller, 12)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The older build's outcome heard later registers nothing new about the list: the newer stands.
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, catalogueBuild("b-5",
|
||||||
|
notifierFallingBackTo("font-size", "number"), theFirstKeys, 11)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kept, _, _ = open.inventory.PredefinedKept(ctx)
|
||||||
|
if kept.Build != "b-4" || len(kept.Keys) != 1 || len(catalogue.PublishedPredefined()) != 1 {
|
||||||
|
t.Fatalf("an older build's list heard later replaced the newer one: %+v", kept)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A build of another repository publishes nothing, and its manifest is judged against the published list.
|
||||||
|
other := catalogueBuild("b-6", notifierFallingBackTo("font-size", "number"), theFirstKeys, 99)
|
||||||
|
other.Repository, other.Source.Repository = "http://forge.internal:20000/novox/notifier.git", "novox/notifier"
|
||||||
|
other.Manifest = json.RawMessage(strings.Replace(notifierFallingBackTo("font-size", "number"), `"notifier"`,
|
||||||
|
`"chimes"`, 1))
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, other)); err != nil {
|
||||||
|
t.Fatalf("a module of another repository naming a published key: %v", err)
|
||||||
|
}
|
||||||
|
if kept, _, _ = open.inventory.PredefinedKept(ctx); kept.Build != "b-4" {
|
||||||
|
t.Fatalf("a build of another repository published its list: %+v", kept)
|
||||||
|
}
|
||||||
|
other.ID, other.Manifest = "b-7", json.RawMessage(strings.Replace(notifierFallingBackTo("agent-managed-settings",
|
||||||
|
"number"), `"notifier"`, `"chimes"`, 1))
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, other); err == nil || !strings.Contains(err.Error(), "not on the list") {
|
||||||
|
t.Fatalf("a key only another repository's list holds was registered: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,9 @@ package main
|
|||||||
// it beside the layer (`settings` says it back), and records the warrant in the hand-act log on the bus, where a
|
// it beside the layer (`settings` says it back), and records the warrant in the hand-act log on the bus, where a
|
||||||
// person's decisions are read; the router edits the ask on every channel to its outcome. No seat event of its
|
// person's decisions are read; the router edits the ask on every channel to its outcome. No seat event of its
|
||||||
// own: nothing consumes one, and the installer's first user list in the node-engine's repository names every
|
// own: nothing consumes one, and the installer's first user list in the node-engine's repository names every
|
||||||
// controller event, so one would cost a node-engine change for a fact without a reader. The push afterwards is a
|
// controller event, so one would cost a node-engine change for a fact without a reader. No push is left for
|
||||||
// separate act, as it is for a layer set at the terminal.
|
// afterwards: the warrant that sets the layer opens the walk that sends it (novox/hq ADR 0344, settings_walk.go),
|
||||||
|
// so the operator's one Approve is the act that makes the change take effect.
|
||||||
//
|
//
|
||||||
// **What the operator reads** is the module, the machine, each key with its exact new value and, for a changed
|
// **What the operator reads** is the module, the machine, each key with its exact new value and, for a changed
|
||||||
// key, the value it replaces. A value that may not leave the mesh (an address, a path, a secret's shape: the
|
// key, the value it replaces. A value that may not leave the mesh (an address, a path, a secret's shape: the
|
||||||
@@ -27,6 +28,13 @@ package main
|
|||||||
// ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the
|
// ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the
|
||||||
// phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left
|
// phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left
|
||||||
// waiting for an answer that cannot come.
|
// waiting for an answer that cannot come.
|
||||||
|
//
|
||||||
|
// **On a channel that proves who answers, the values are shown WHOLE** (novox/hq issue 383, the operator's
|
||||||
|
// decision of 2026-10-10): a mount point, a share name or a private address is the thing being approved and must
|
||||||
|
// be readable, so the ask carries them whole beside the explanation (asks.Ask.Whole), the router shows that only
|
||||||
|
// on a kind that verifies its sender, and only a value shaped like a secret is withheld there. The message is the
|
||||||
|
// headline, one line per key, who proposed it and when; the fingerprint and how to read the proposal whole are
|
||||||
|
// the Details answer's (asks.Ask.Details). The masking stays for conditions and for channels that prove nothing.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -47,6 +55,7 @@ import (
|
|||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/conditions"
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
"github.com/novox/mesh-controller/internal/outward"
|
"github.com/novox/mesh-controller/internal/outward"
|
||||||
)
|
)
|
||||||
@@ -154,47 +163,48 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
|
|||||||
if len([]rune(headline)) > asks.HeadlineLength {
|
if len([]rune(headline)) > asks.HeadlineLength {
|
||||||
headline = verb + " settings?"
|
headline = verb + " settings?"
|
||||||
}
|
}
|
||||||
shown, whole := p.change(machines)
|
// The message reads at a glance (issues 383 and 639): a summary of what changes where, then at most three
|
||||||
var b strings.Builder
|
// points, a key each; every key with its whole value, who proposed it, the fingerprint and how to read it at the
|
||||||
if p.Clear {
|
// terminal are its Details, which the router notes after the answers.
|
||||||
fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where())
|
shown, shownAll, shownWhole := p.change(machines, false)
|
||||||
} else {
|
whole, wholeAll, _ := p.change(machines, true)
|
||||||
fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where())
|
summary := p.summary()
|
||||||
|
compose := func(points []string) string {
|
||||||
|
return strings.Join(append([]string{summary}, points...), "\n")
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"))
|
// The Details: every setting it sets, one line each, uncut — whole where the sender is proven (WholeDetails),
|
||||||
switch {
|
// masked elsewhere — then who proposed it, the fingerprint and how to read it at the terminal.
|
||||||
case p.Clear && p.HadLayer:
|
details := func(lines []string) string {
|
||||||
b.WriteString("The layer it removes:\n\n")
|
var d strings.Builder
|
||||||
case p.Clear:
|
d.WriteString(strings.Join(lines, "\n") + "\n")
|
||||||
b.WriteString("There is no layer to remove; approving changes nothing.")
|
d.WriteString(p.onApprove() + "\n")
|
||||||
case !p.HadLayer:
|
fmt.Fprintf(&d, "Proposed by %s at %s. Fingerprint %s.\n", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"),
|
||||||
b.WriteString("There is no layer yet; this is the whole of it:\n\n")
|
fingerprint(p.Digest))
|
||||||
default:
|
if !shownWhole {
|
||||||
b.WriteString("The layer is replaced whole; what changes against it:\n\n")
|
d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n")
|
||||||
}
|
}
|
||||||
b.WriteString(shown)
|
fmt.Fprintf(&d, "Read it whole, with this fingerprint: settings proposals %s at the controller's terminal, or the "+
|
||||||
fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest))
|
"controller's settings verb with proposal %s through the mesh MCP server.", id, id)
|
||||||
if !whole {
|
return d.String()
|
||||||
b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " +
|
|
||||||
"this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " +
|
|
||||||
"mesh-cli settings proposals " + id + ".")
|
|
||||||
}
|
}
|
||||||
if p.Clear {
|
q := asks.Ask{ID: id, Headline: headline, Explanation: compose(shown), Who: asks.Operator,
|
||||||
b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.")
|
|
||||||
} else {
|
|
||||||
b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.")
|
|
||||||
}
|
|
||||||
q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator,
|
|
||||||
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
|
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
|
||||||
About: p.about()}
|
About: p.about(), Details: details(shownAll)}
|
||||||
|
// Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere.
|
||||||
|
if w := compose(whole); w != q.Explanation {
|
||||||
|
q.Whole = w
|
||||||
|
}
|
||||||
|
if w := details(wholeAll); w != q.Details {
|
||||||
|
q.WholeDetails = w
|
||||||
|
}
|
||||||
options := map[string]int{}
|
options := map[string]int{}
|
||||||
for i, act := range p.actions(id) {
|
for i, act := range p.actions(id) {
|
||||||
binds, _ := asks.ActDigest(boundAct(act))
|
binds, _ := asks.ActDigest(boundAct(act))
|
||||||
oid := optionID(act.Label)
|
oid := optionID(act.Label)
|
||||||
options[oid] = i
|
options[oid] = i
|
||||||
does := "the settings are set; nothing is pushed yet"
|
does := "the settings are set and sent to " + p.reaches() + " at once, each machine judged"
|
||||||
if p.Clear {
|
if p.Clear {
|
||||||
does = "the layer is removed; nothing is pushed yet"
|
does = "the layer is removed and that is sent to " + p.reaches() + " at once, each machine judged"
|
||||||
}
|
}
|
||||||
if act.Arguments["answer"] == answerDecline {
|
if act.Arguments["answer"] == answerDecline {
|
||||||
does = "nothing changes; the proposal is discarded"
|
does = "nothing changes; the proposal is discarded"
|
||||||
@@ -205,55 +215,141 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
|
|||||||
return q, options
|
return q, options
|
||||||
}
|
}
|
||||||
|
|
||||||
// shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`.
|
// summary is the proposal's first line, what changes where: how many settings, how many stay, and that Approve sends
|
||||||
const shownMost = 1400
|
// the layer at once by a walk, one machine at a time (novox/hq ADR 0344); the rest of what Approve does
|
||||||
|
// (onApprove) is the Details'.
|
||||||
|
func (p settingsProposal) summary() string {
|
||||||
|
if p.Clear {
|
||||||
|
if !p.HadLayer {
|
||||||
|
return "There is no layer to remove; approving changes nothing."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Removes the %d setting(s) of %s set on %s; approved, the layer is removed and that is sent at once "+
|
||||||
|
"by a walk to %s, one machine at a time, each judged.", len(leaves(p.Before, "")), p.Module, p.where(), p.reaches())
|
||||||
|
}
|
||||||
|
added, changed, removed := settingsChange(p.Before, p.Values)
|
||||||
|
n := len(added) + len(changed) + len(removed)
|
||||||
|
unchanged := len(leaves(p.Values, "")) - len(added) - len(changed)
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Sprintf("Nothing changes: the %d setting(s) of %s on %s are as they stand.", unchanged, p.Module, p.where())
|
||||||
|
}
|
||||||
|
stay := ""
|
||||||
|
if unchanged > 0 {
|
||||||
|
stay = fmt.Sprintf(", and %d stay as they are", unchanged)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Changes %d setting(s) of %s on %s%s; approved, the layer is set and sent at once by a walk to %s, "+
|
||||||
|
"one machine at a time, each judged.", n, p.Module, p.where(), stay, p.reaches())
|
||||||
|
}
|
||||||
|
|
||||||
// change is what changes, line by line, each value shown under the content rule, and whether every value was
|
// change is what changes: as at most asks.MostPoints points for the glance, as every line whole for the note, and
|
||||||
// shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the
|
// whether every value was shown exactly: "+ key: value"
|
||||||
// count of keys unchanged.
|
// added, "~ key: value (was: old)" changed, "- key (was: old)" removed. Each value is shown under the content rule
|
||||||
func (p settingsProposal) change(machines []string) (string, bool) {
|
// (sayableValue), or — exact, for a channel that proves who answers — as it is, withheld only when shaped like a
|
||||||
|
// secret (wholeValue). More changes than points: the first two, and the keys of the rest; a point longer than a
|
||||||
|
// phone shows at a glance is cut and says so. The note carries every line uncut (the review of #304: Approve sets
|
||||||
|
// exactly what the operator can read).
|
||||||
|
func (p settingsProposal) change(machines []string, exact bool) ([]string, []string, bool) {
|
||||||
whole := true
|
whole := true
|
||||||
say := func(v any) string {
|
say := func(v any) string {
|
||||||
s, w := sayableValue(v, machines)
|
s, w := sayableValue(v, machines)
|
||||||
|
if exact {
|
||||||
|
s, w = wholeValue(v, machines)
|
||||||
|
}
|
||||||
whole = whole && w
|
whole = whole && w
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
var lines []string
|
type line struct{ key, text string }
|
||||||
|
var lines []line
|
||||||
if p.Clear {
|
if p.Clear {
|
||||||
was := leaves(p.Before, "")
|
was := leaves(p.Before, "")
|
||||||
for _, k := range layerKeys(was) {
|
for _, k := range layerKeys(was) {
|
||||||
lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k])))
|
lines = append(lines, line{k, fmt.Sprintf("- %s: %s", k, say(was[k]))})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
added, changed, removed := settingsChange(p.Before, p.Values)
|
added, changed, removed := settingsChange(p.Before, p.Values)
|
||||||
was, now := leaves(p.Before, ""), leaves(p.Values, "")
|
was, now := leaves(p.Before, ""), leaves(p.Values, "")
|
||||||
for _, k := range added {
|
for _, k := range added {
|
||||||
lines = append(lines, fmt.Sprintf("+ %s: %s", k, say(now[k])))
|
lines = append(lines, line{k, fmt.Sprintf("+ %s: %s", k, say(now[k]))})
|
||||||
}
|
}
|
||||||
for _, k := range changed {
|
for _, k := range changed {
|
||||||
lines = append(lines, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k])))
|
lines = append(lines, line{k, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k]))})
|
||||||
}
|
}
|
||||||
for _, k := range removed {
|
for _, k := range removed {
|
||||||
lines = append(lines, fmt.Sprintf("- %s (was: %s)", k, say(was[k])))
|
lines = append(lines, line{k, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))})
|
||||||
}
|
|
||||||
unchanged := len(now) - len(added) - len(changed)
|
|
||||||
switch {
|
|
||||||
case len(lines) == 0 && unchanged > 0:
|
|
||||||
lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged))
|
|
||||||
case unchanged > 0:
|
|
||||||
lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out := strings.Join(lines, "\n")
|
// **Each line is judged as the router will judge it** (the review of #304, ADR 0347 §1 and §3): a line the
|
||||||
if len(out) > shownMost {
|
// content rule (masked) or the secret rule (whole) would refuse — a value given to a secret's name, say
|
||||||
cut := shownMost
|
// "+ password: hunter2" — is said with its key still named and its value withheld, never left for the
|
||||||
for cut > 0 && out[cut] != '\n' {
|
// router to withhold whole and unnamed. A line refused even so is the propose's refusal (judgeLines).
|
||||||
cut--
|
for i, l := range lines {
|
||||||
|
if lineLeaves(l.text, exact, machines) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut)
|
lines[i].text = l.text[:2] + l.key + " (value withheld)"
|
||||||
whole = false
|
whole = false
|
||||||
}
|
}
|
||||||
return out, whole
|
all := make([]string, len(lines))
|
||||||
|
for i, l := range lines {
|
||||||
|
all[i] = l.text
|
||||||
|
}
|
||||||
|
// What the note holds of a value: whole where the glance is the whole one (shown only where the sender is
|
||||||
|
// proven, with the whole details beside it), under the content rule elsewhere — so the marker says no more than
|
||||||
|
// the note it points to (the review of ADR 0347).
|
||||||
|
inNote := "in the note"
|
||||||
|
if exact {
|
||||||
|
inNote = "whole in the note"
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for i, l := range lines {
|
||||||
|
if len(lines) > asks.MostPoints && i == asks.MostPoints-1 {
|
||||||
|
var rest []string
|
||||||
|
for _, r := range lines[i:] {
|
||||||
|
rest = append(rest, r.key)
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf("and %d more, each %s: %s", len(rest), inNote, strings.Join(rest, ", ")))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, l.text)
|
||||||
|
}
|
||||||
|
// A point too long for a glance is cut, and says so: its value is the note's.
|
||||||
|
cut := "… (cut: " + inNote + ")"
|
||||||
|
for i, l := range out {
|
||||||
|
if r := []rune(l); len(r) > asks.PointLength {
|
||||||
|
out[i] = string(r[:asks.PointLength-len([]rune(cut))]) + cut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, all, whole
|
||||||
|
}
|
||||||
|
|
||||||
|
// lineLeaves says whether a line may leave the mesh as the router judges it: under the content rule for the masked
|
||||||
|
// form, the secret rule alone for the whole one.
|
||||||
|
func lineLeaves(line string, exact bool, machines []string) bool {
|
||||||
|
if exact {
|
||||||
|
_, ok := outward.Secret(line, machines...)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
_, ok := outward.Check(line, machines...)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeLines is the first line of an ask's details the router would refuse, or withhold, and why: masked under
|
||||||
|
// the content rule line by line, whole under the secret rule. Empty when every line may leave.
|
||||||
|
func judgeLines(details string, exact bool, machines []string) (string, string) {
|
||||||
|
for _, line := range strings.Split(details, "\n") {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if exact {
|
||||||
|
if refusal, ok := outward.Secret(line, machines...); !ok {
|
||||||
|
return line, refusal.String()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if refusal, ok := outward.Check(line, machines...); !ok {
|
||||||
|
return line, refusal.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func layerKeys(m map[string]any) []string {
|
func layerKeys(m map[string]any) []string {
|
||||||
@@ -300,6 +396,27 @@ func sayableValue(v any, machines []string) (string, bool) {
|
|||||||
return out, out == text
|
return out, out == text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wholeValue is a value as a channel that proves who answers is shown it (asks.Ask.Whole), and whether it was
|
||||||
|
// shown whole: as it is, unless it is shaped like a secret (outward.Secret), which is withheld whole — never in
|
||||||
|
// part, so no half of a key reaches the phone.
|
||||||
|
func wholeValue(v any, machines []string) (string, bool) {
|
||||||
|
text, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
raw, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return markWithheld, false
|
||||||
|
}
|
||||||
|
text = string(raw)
|
||||||
|
}
|
||||||
|
if text == "" {
|
||||||
|
return `""`, true
|
||||||
|
}
|
||||||
|
if _, ok := outward.Secret(text, machines...); !ok {
|
||||||
|
return markWithheld, false
|
||||||
|
}
|
||||||
|
return text, true
|
||||||
|
}
|
||||||
|
|
||||||
// sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make
|
// sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make
|
||||||
// pass is withheld whole.
|
// pass is withheld whole.
|
||||||
func sayable(text string, machines []string) string {
|
func sayable(text string, machines []string) string {
|
||||||
@@ -432,6 +549,23 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error)
|
|||||||
return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+
|
return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+
|
||||||
"shown without it; %s", refusal, atTheTerminalInstead(in))
|
"shown without it; %s", refusal, atTheTerminalInstead(in))
|
||||||
}
|
}
|
||||||
|
// The whole words are shown only where the sender is proven, and never a secret's shape: wholeValue withholds
|
||||||
|
// one, and the router would refuse the ask if one were left, so it is refused here first, in words.
|
||||||
|
if refusal, ok := outward.Secret(q.Whole, machines...); !ok {
|
||||||
|
return refuse("the change shown whole would carry %s, which may not leave the mesh on any channel; %s",
|
||||||
|
refusal, atTheTerminalInstead(in))
|
||||||
|
}
|
||||||
|
// So are the details (ADR 0347 §1, §3): every line the router would withhold from the note, or refuse the ask
|
||||||
|
// for, is refused here, in the controller's own words, naming the line's key — Approve never sets a value the
|
||||||
|
// operator was shown nothing of.
|
||||||
|
if line, why := judgeLines(q.Details, false, machines); line != "" {
|
||||||
|
return refuse("the change's line %q would carry %s, which the operator's note may not show; %s",
|
||||||
|
keyOfLine(line), why, atTheTerminalInstead(in))
|
||||||
|
}
|
||||||
|
if line, why := judgeLines(q.WholeDetails, true, machines); line != "" {
|
||||||
|
return refuse("the change's line %q shown whole would carry %s, which may not leave the mesh on any channel; %s",
|
||||||
|
keyOfLine(line), why, atTheTerminalInstead(in))
|
||||||
|
}
|
||||||
// Fail closed, before anything is kept: no router, no grant, no channel means no ask.
|
// Fail closed, before anything is kept: no router, no grant, no channel means no ask.
|
||||||
if pr.routerHere != nil {
|
if pr.routerHere != nil {
|
||||||
here, err := pr.routerHere(ctx)
|
here, err := pr.routerHere(ctx)
|
||||||
@@ -519,15 +653,29 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error)
|
|||||||
fingerprint(p.Digest))
|
fingerprint(p.Digest))
|
||||||
fmt.Fprintf(&b, " until %s nothing changes: the layer is set only on Approve, and discarded on Decline or at expiry\n",
|
fmt.Fprintf(&b, " until %s nothing changes: the layer is set only on Approve, and discarded on Decline or at expiry\n",
|
||||||
q.Expires.Local().Format("2006-01-02 15:04"))
|
q.Expires.Local().Format("2006-01-02 15:04"))
|
||||||
fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, `push %s` sends it", id, pushWord(p.Node))
|
fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, a walk sends it to %s at once, one machine at "+
|
||||||
|
"a time, each judged, and its end is told as every walk's is (novox/hq ADR 0344)", id, p.reaches())
|
||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushWord(node string) string {
|
// reaches is the machines an approved layer is sent to, in words: the one it names, or every machine running the
|
||||||
if node == "" {
|
// module.
|
||||||
return "--behind"
|
func (p settingsProposal) reaches() string {
|
||||||
|
if p.Node == "" {
|
||||||
|
return "every machine running " + p.Module
|
||||||
}
|
}
|
||||||
return node
|
return p.Node
|
||||||
|
}
|
||||||
|
|
||||||
|
// onApprove is what the ask says Approve does (novox/hq ADR 0344 §4): the one Approve sets the layer and sends it.
|
||||||
|
func (p settingsProposal) onApprove() string {
|
||||||
|
what := "the layer is set"
|
||||||
|
if p.Clear {
|
||||||
|
what = "the layer is removed"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Approved, %s and sent at once to %s, one machine at a time, each judged, every build there "+
|
||||||
|
"kept; its end is told as every walk's is. A module that reads its settings only when it starts takes them at "+
|
||||||
|
"its next start.", what, p.reaches())
|
||||||
}
|
}
|
||||||
|
|
||||||
func newProposalID() string {
|
func newProposalID() string {
|
||||||
@@ -788,6 +936,40 @@ func setLayerIn(open *stores) setOnWarrant {
|
|||||||
changed = "nothing changed"
|
changed = "nothing changed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changed + " (ask " + askID + ")", nil
|
// **And sends it** (novox/hq ADR 0344): the layer the operator approved is walked to the machines it reaches
|
||||||
|
// at once. A walk that cannot be opened is said as the act's failure — the layer is set and nothing sends it.
|
||||||
|
walked, err := openSettingsWalk(ctx, open, inventory.PlanSettings{Module: p.Module, Node: p.Node, Ask: askID,
|
||||||
|
Change: changed})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s (ask %s) is set, and the walk that sends it could not be opened: %v — `push %s` sends it",
|
||||||
|
changed, askID, err, pushTo(p.Node))
|
||||||
|
}
|
||||||
|
return changed + " (ask " + askID + "); " + walked, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushTo is the push that sends a layer by hand when its walk could not: the machine, or every one behind.
|
||||||
|
func pushTo(node string) string {
|
||||||
|
if node == "" {
|
||||||
|
return "--behind"
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyOfLine is what a refusal names of a line: the key of a change's line ("+ key: …"), or the line's first words.
|
||||||
|
func keyOfLine(line string) string {
|
||||||
|
if len(line) > 2 && strings.ContainsRune("+~-=", rune(line[0])) && line[1] == ' ' {
|
||||||
|
rest := line[2:]
|
||||||
|
if i := strings.IndexAny(rest, ": "); i > 0 {
|
||||||
|
rest = rest[:i]
|
||||||
|
}
|
||||||
|
if _, ok := outward.Secret(rest); !ok {
|
||||||
|
return "whose key is shaped like a secret"
|
||||||
|
}
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
if r := []rune(line); len(r) > 40 {
|
||||||
|
return string(r[:40]) + "…"
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -116,24 +117,43 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
|||||||
if q.Options[0].Binds == q.Options[1].Binds {
|
if q.Options[0].Binds == q.Options[1].Binds {
|
||||||
t.Error("Approve and Decline bind the same act")
|
t.Error("Approve and Decline bind the same act")
|
||||||
}
|
}
|
||||||
for _, line := range []string{
|
// The message reads at a glance (issues 383 and 639): a summary, then one point per key — and nothing else:
|
||||||
"Set the settings of mounts on shanks to these values?",
|
// who proposed it, the fingerprint and the how-to are the Details, which the router notes after the answers.
|
||||||
"Proposed by g14/claude-code, through the mesh-controller seat, at " + r.now.Local().Format("15:04 on 2 Jan") + ".",
|
summary := "Changes 3 setting(s) of mounts on shanks; approved, the layer is set and sent at once by a walk to shanks, one machine at a time, each judged."
|
||||||
"+ sources: recalbox=‹address›@‹path›:ro",
|
if q.Explanation != summary+"\n+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)" {
|
||||||
"~ shares: library=‹path› (was: none)",
|
t.Errorf("the explanation:\n%s", q.Explanation)
|
||||||
"- old (was: x)",
|
|
||||||
"Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
|
|
||||||
"read it whole, with this fingerprint",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(q.Explanation, line) {
|
|
||||||
t.Errorf("the explanation lacks %q:\n%s", line, q.Explanation)
|
|
||||||
}
|
}
|
||||||
|
if err := asks.Glance(q.Explanation); err != nil {
|
||||||
|
t.Error(err)
|
||||||
}
|
}
|
||||||
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
|
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
|
||||||
if strings.Contains(q.Explanation, leak) {
|
if strings.Contains(q.Explanation, leak) {
|
||||||
t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
|
t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Where the sender is proven, the values whole: the mount point and the share are what is approved.
|
||||||
|
if q.Whole != summary+"\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro\n~ shares: library=/mnt/library (was: none)\n- old (was: x)" ||
|
||||||
|
asks.Glance(q.Whole) != nil {
|
||||||
|
t.Errorf("the whole words:\n%s", q.Whole)
|
||||||
|
}
|
||||||
|
for _, line := range []string{
|
||||||
|
"Proposed by g14/claude-code, through the mesh-controller seat at " + r.now.Local().Format("15:04 on 2 Jan") +
|
||||||
|
". Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
|
||||||
|
"Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.",
|
||||||
|
"Read it whole, with this fingerprint: settings proposals " + kept(r).ID + " at the controller's terminal",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(q.Details, line) {
|
||||||
|
t.Errorf("Details lack %q:\n%s", line, q.Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if refusal, ok := outward.Check(q.Details, "anchor", "laptop", "shanks"); !ok {
|
||||||
|
t.Errorf("the router would withhold a line of the details: %s", refusal)
|
||||||
|
}
|
||||||
|
for _, howTo := range []string{"ingerprint", "settings proposals", "settings verb", "not proven", "Proposed by"} {
|
||||||
|
if strings.Contains(q.Explanation, howTo) || strings.Contains(q.Whole, howTo) {
|
||||||
|
t.Errorf("the message carries the how-to %q", howTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
all := []string{q.Headline, q.Explanation, q.OnExpiry}
|
all := []string{q.Headline, q.Explanation, q.OnExpiry}
|
||||||
for _, o := range q.Options {
|
for _, o := range q.Options {
|
||||||
all = append(all, o.Label, o.Does)
|
all = append(all, o.Label, o.Does)
|
||||||
@@ -141,6 +161,9 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
|||||||
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
|
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
|
||||||
t.Errorf("the router would refuse the ask: %s", refusal)
|
t.Errorf("the router would refuse the ask: %s", refusal)
|
||||||
}
|
}
|
||||||
|
if refusal, ok := outward.Secret(q.Whole, "anchor", "laptop", "shanks"); !ok {
|
||||||
|
t.Errorf("the router would refuse the whole words: %s", refusal)
|
||||||
|
}
|
||||||
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
|
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
|
||||||
kept := r.theProposal(t)
|
kept := r.theProposal(t)
|
||||||
p := kept.Proposal
|
p := kept.Proposal
|
||||||
@@ -173,11 +196,98 @@ func TestAMeshWideLayerIsProposed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
q := r.askSent(t)
|
q := r.askSent(t)
|
||||||
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
|
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
|
||||||
!strings.Contains(q.Explanation, "Set the settings of notes on the whole mesh to these values?") {
|
q.Explanation != "Changes 1 setting(s) of notes on the whole mesh; approved, the layer is set and sent at once by a walk to every machine running notes, one machine at a time, each judged.\n+ x: 1" ||
|
||||||
|
q.Whole != "" {
|
||||||
t.Errorf("%+v", q)
|
t.Errorf("%+v", q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kept is the one proposal the rig keeps.
|
||||||
|
func kept(r *proposerRig) asked {
|
||||||
|
for _, a := range r.store {
|
||||||
|
if a.Proposal != nil {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return asked{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The switch between the masked and the whole change (issue 383): the same change, under the content rule and
|
||||||
|
// exact, differs in the masked values alone; a value shaped like a secret is withheld in both, whole, with its
|
||||||
|
// key still named; and a change no value of which is masked carries no whole words of its own.
|
||||||
|
func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t *testing.T) {
|
||||||
|
machines := []string{"anchor", "laptop", "shanks"}
|
||||||
|
r := newProposerRig(t)
|
||||||
|
values := map[string]any{"shares": "media=/storage/media", "sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "font": "Inter 13"}
|
||||||
|
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
q := r.askSent(t)
|
||||||
|
masked, _, _ := kept(r).Proposal.change(machines, false)
|
||||||
|
whole, _, _ := kept(r).Proposal.change(machines, true)
|
||||||
|
if !strings.HasSuffix(q.Explanation, "\n"+strings.Join(masked, "\n")) || !strings.HasSuffix(q.Whole, "\n"+strings.Join(whole, "\n")) {
|
||||||
|
t.Fatalf("the ask does not carry the change masked and whole:\n%s\n--\n%s", q.Explanation, q.Whole)
|
||||||
|
}
|
||||||
|
if want := []string{"+ font: Inter 13", "+ shares: media=‹path›", "+ sources: recalbox=‹address›@‹path›"}; strings.Join(masked, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Errorf("masked: %q", masked)
|
||||||
|
}
|
||||||
|
if want := []string{"+ font: Inter 13", "+ shares: media=/storage/media", "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox"}; strings.Join(whole, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Errorf("whole: %q", whole)
|
||||||
|
}
|
||||||
|
if _, ok := outward.Secret(q.Whole, "shanks"); !ok {
|
||||||
|
t.Error("the router would refuse the whole words")
|
||||||
|
}
|
||||||
|
// Mutation: the masked and the whole change differ in exactly the lines whose value was masked.
|
||||||
|
differ := 0
|
||||||
|
for i := range masked {
|
||||||
|
if masked[i] != whole[i] {
|
||||||
|
differ++
|
||||||
|
if !strings.Contains(masked[i], "‹") || strings.Contains(whole[i], "‹") {
|
||||||
|
t.Errorf("the lines differ otherwise than by the mask:\n%s\n%s", masked[i], whole[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if differ != 2 {
|
||||||
|
t.Errorf("%d lines differ, and the mask covered 2", differ)
|
||||||
|
}
|
||||||
|
// A value shaped like a secret is withheld in both, whole, with its key still named.
|
||||||
|
r1 := newProposerRig(t)
|
||||||
|
secrets := map[string]any{"github": "ghp_abcdefghijklmnopqrstuvwxyz0123456789", "cert": "-----BEGIN CERTIFICATE-----"}
|
||||||
|
if _, err := r1.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: secrets}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
q1 := r1.askSent(t)
|
||||||
|
for _, line := range []string{"+ cert: ‹withheld›", "+ github: ‹withheld›"} {
|
||||||
|
if !strings.Contains(q1.Explanation, line) {
|
||||||
|
t.Errorf("lacks %q:\n%s", line, q1.Explanation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"ghp_", "BEGIN CERTIFICATE"} {
|
||||||
|
if strings.Contains(q1.Explanation, secret) || strings.Contains(q1.Whole, secret) || strings.Contains(q1.Details, secret) {
|
||||||
|
t.Errorf("the secret %q reaches the phone", secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// More keys than points (issue 639): the first two, then the keys of the rest, read whole at the terminal.
|
||||||
|
r3 := newProposerRig(t)
|
||||||
|
five := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "e": "5"}
|
||||||
|
if _, err := r3.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: five}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if q3 := r3.askSent(t); !strings.HasSuffix(q3.Explanation, "\n+ a: 1\n+ b: 2\nand 3 more, each in the note: c, d, e") || asks.Glance(q3.Explanation) != nil ||
|
||||||
|
!strings.Contains(q3.Details, "settings proposals") {
|
||||||
|
t.Errorf("five keys:\n%s", q3.Explanation)
|
||||||
|
}
|
||||||
|
// No value masked: the message is the same everywhere, and the ask says no whole words.
|
||||||
|
r2 := newProposerRig(t)
|
||||||
|
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "dunst", node: "laptop", values: map[string]any{"font-size": 13, "width": 500}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if q2 := r2.askSent(t); q2.Whole != "" || !strings.HasSuffix(q2.Explanation, "\n+ font-size: 13\n+ width: 500") ||
|
||||||
|
strings.Contains(q2.Details, "not proven") {
|
||||||
|
t.Errorf("%+v", q2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
|
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
|
||||||
func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
|
func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
|
||||||
r := newAskerRig(t)
|
r := newAskerRig(t)
|
||||||
@@ -205,7 +315,8 @@ func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) {
|
|||||||
}
|
}
|
||||||
q := r.askSent(t)
|
q := r.askSent(t)
|
||||||
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
|
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
|
||||||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") {
|
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") || !strings.Contains(q.Whole, "- places.data.path: /srv/notes") ||
|
||||||
|
!strings.HasPrefix(q.Explanation, "Removes the 2 setting(s) of notes set on laptop; approved, the layer is removed and that is sent at once by a walk to laptop, one machine at a time") {
|
||||||
t.Errorf("%+v", q)
|
t.Errorf("%+v", q)
|
||||||
}
|
}
|
||||||
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
|
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
|
||||||
@@ -744,3 +855,125 @@ func captureStdout(t *testing.T, f func()) string {
|
|||||||
_ = w.Close()
|
_ = w.Close()
|
||||||
return <-done
|
return <-done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The review of #304: Approve sets exactly what the operator can read. A proposal of five keys, one value 300
|
||||||
|
// characters long, shows two keys at a glance, says what it cut, and carries every key with its whole value in its
|
||||||
|
// Details for a channel that proves who answers (WholeDetails), and every key masked for one that does not.
|
||||||
|
func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) {
|
||||||
|
var long strings.Builder
|
||||||
|
long.WriteString("library=/storage")
|
||||||
|
for i := 0; long.Len() < 300; i++ {
|
||||||
|
long.WriteString(fmt.Sprintf("/album-%02d", i))
|
||||||
|
}
|
||||||
|
value := long.String()[:300]
|
||||||
|
values := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value}
|
||||||
|
r := newProposerRig(t)
|
||||||
|
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
q := r.askSent(t)
|
||||||
|
if err := q.Check(r.now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// What the operator reads on a proving channel: the whole words, then the note's whole details.
|
||||||
|
visible := q.Whole + "\n" + q.WholeDetails
|
||||||
|
if q.Whole == "" {
|
||||||
|
visible = q.Explanation + "\n" + q.WholeDetails
|
||||||
|
}
|
||||||
|
for k, v := range values {
|
||||||
|
if !strings.Contains(visible, "+ "+k+": "+v.(string)) {
|
||||||
|
t.Errorf("the operator never reads %s whole:\n%s", k, visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The masked glance points to a note that holds the values masked, and says no more; the whole glance, shown only
|
||||||
|
// beside the whole details, says they are whole there.
|
||||||
|
if asks.Glance(q.Explanation) != nil || !strings.HasSuffix(q.Explanation, "\nand 3 more, each in the note: c, d, shares") ||
|
||||||
|
strings.Contains(q.Explanation, "whole in the note") || !strings.HasSuffix(q.Whole, "\nand 3 more, each whole in the note: c, d, shares") {
|
||||||
|
t.Errorf("the glance:\n%s", q.Explanation)
|
||||||
|
}
|
||||||
|
for k := range values {
|
||||||
|
if !strings.Contains(q.Details, "+ "+k+": ") {
|
||||||
|
t.Errorf("the masked details lack %s:\n%s", k, q.Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(q.Details, "/storage") {
|
||||||
|
t.Errorf("the masked details show a path:\n%s", q.Details)
|
||||||
|
}
|
||||||
|
if refusal, ok := outward.Check(q.Details, "shanks"); !ok {
|
||||||
|
t.Errorf("the router would withhold the masked details: %s", refusal)
|
||||||
|
}
|
||||||
|
if _, ok := outward.Secret(q.WholeDetails, "shanks"); !ok {
|
||||||
|
t.Error("the router would refuse the whole details")
|
||||||
|
}
|
||||||
|
// A value too long for a glance is cut there, and says so.
|
||||||
|
r2 := newProposerRig(t)
|
||||||
|
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: map[string]any{"shares": value}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
q2 := r2.askSent(t)
|
||||||
|
// The cut point is 160 characters, its marker inside the bound.
|
||||||
|
if last := q2.Whole[strings.LastIndex(q2.Whole, "\n")+1:]; len([]rune(last)) != asks.PointLength {
|
||||||
|
t.Errorf("the cut point is %d characters, not %d: %q", len([]rune(last)), asks.PointLength, last)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(q2.Whole, "… (cut: whole in the note)") || !strings.HasSuffix(q2.Explanation, "‹path›") && !strings.HasSuffix(q2.Explanation, "… (cut: in the note)") || !strings.Contains(q2.WholeDetails, "+ shares: "+value) || asks.Glance(q2.Whole) != nil {
|
||||||
|
t.Errorf("the cut is not said, or the note lacks the whole:\n%s\n--\n%s", q2.Whole, q2.WholeDetails)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The review of #304: a value given to a secret's name, past the second key, is said with its key named and its
|
||||||
|
// value withheld, in the note on every channel; the router has nothing left to withhold unnamed or refuse. A line
|
||||||
|
// the router would refuse even so is refused at propose, in the controller's words.
|
||||||
|
func TestASecretNamedKeyIsNamedAndItsValueWithheld(t *testing.T) {
|
||||||
|
r := newProposerRig(t)
|
||||||
|
values := map[string]any{"a": "1", "b": "2", "c": "3", "password": "hunter2"}
|
||||||
|
if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: values}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
q := r.askSent(t)
|
||||||
|
for _, form := range []string{q.Details, q.WholeDetails, q.Explanation, q.Whole} {
|
||||||
|
if strings.Contains(form, "hunter2") {
|
||||||
|
t.Errorf("the value reaches the phone:\n%s", form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(q.Details, "+ password (value withheld)") {
|
||||||
|
t.Errorf("the key is not named with its value withheld:\n%s", q.Details)
|
||||||
|
}
|
||||||
|
if q.WholeDetails != "" && !strings.Contains(q.WholeDetails, "+ password (value withheld)") {
|
||||||
|
t.Errorf("the whole details do not name the key:\n%s", q.WholeDetails)
|
||||||
|
}
|
||||||
|
if line, why := judgeLines(q.Details, false, nil); line != "" {
|
||||||
|
t.Errorf("the router would withhold %q: %s", line, why)
|
||||||
|
}
|
||||||
|
if line, why := judgeLines(q.WholeDetails, true, nil); line != "" {
|
||||||
|
t.Errorf("the router would refuse %q: %s", line, why)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(q.Explanation, "\nand 2 more, each in the note: c, password") {
|
||||||
|
t.Errorf("the glance:\n%s", q.Explanation)
|
||||||
|
}
|
||||||
|
// A key shaped like a secret cannot be named on any channel: refused at propose, in the controller's words, and
|
||||||
|
// the key itself not echoed.
|
||||||
|
r2 := newProposerRig(t)
|
||||||
|
secretKey := "ghp_abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
_, err := r2.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop",
|
||||||
|
values: map[string]any{"a": "1", "b": "2", secretKey: "3"}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "Nothing was proposed") || strings.Contains(err.Error(), secretKey) {
|
||||||
|
t.Errorf("a secret-shaped key: %v", err)
|
||||||
|
}
|
||||||
|
if len(r2.sent) != 0 {
|
||||||
|
t.Errorf("the refused proposal was asked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyOfLine names a change's key, and never one shaped like a secret.
|
||||||
|
func TestARefusalNamesTheKeyAndNeverASecret(t *testing.T) {
|
||||||
|
for line, want := range map[string]string{
|
||||||
|
"+ password (value withheld)": "password",
|
||||||
|
"~ shares: ‹path› (was: none)": "shares",
|
||||||
|
"+ ghp_abcdefghijklmnopqrstuvwxyz0123456789: 1": "whose key is shaped like a secret",
|
||||||
|
"Proposed by g14 at 15:04.": "Proposed by g14 at 15:04.",
|
||||||
|
} {
|
||||||
|
if got := keyOfLine(line); got != want {
|
||||||
|
t.Errorf("%q: %q, want %q", line, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -249,7 +249,13 @@ func serve(ctx context.Context) (err error) {
|
|||||||
// (novox/hq to-be 45 §9).
|
// (novox/hq to-be 45 §9).
|
||||||
checkEvents = bus
|
checkEvents = bus
|
||||||
// And every walk this controller keeps in a new state, for the delivery it walks (novox/hq ADR 0239).
|
// And every walk this controller keeps in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||||
inventory.PlanSaved = func(_ *inventory.Inventory, p inventory.Plan) { sayPlanMoved(ctx, bus, open.inventory, p) }
|
inventory.PlanSaved = func(_ *inventory.Inventory, p inventory.Plan) {
|
||||||
|
sayPlanMoved(ctx, bus, open.inventory, p)
|
||||||
|
// A walk ended has taken what it reuses: its merges' check builds go (novox/hq ADR 0350 §5).
|
||||||
|
if p.WalkSaid == inventory.WalkEnded {
|
||||||
|
go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := server.Checks(following{open}); err != nil {
|
if err := server.Checks(following{open}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -258,6 +264,9 @@ func serve(ctx context.Context) (err error) {
|
|||||||
// The facts snapshot a merge check is fed (novox/hq to-be 45 §9): kept current while this
|
// The facts snapshot a merge check is fed (novox/hq to-be 45 §9): kept current while this
|
||||||
// controller holds the lease, read by the build seat from the artifact store.
|
// controller holds the lease, read by the build seat from the artifact store.
|
||||||
go exportingFacts(ctx, open, bus.Conn.ConnectedServerVersion)
|
go exportingFacts(ctx, open, bus.Conn.ConnectedServerVersion)
|
||||||
|
// The record mirrors (novox/hq ADR 0289, design 57 F3): the assignments and each registered module's
|
||||||
|
// declared config on the bus, written from the store only when a record changed, by the lease holder.
|
||||||
|
go mirroringRecords(ctx, inv, bus.Conn, bus)
|
||||||
// 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) })
|
||||||
@@ -740,7 +749,14 @@ func waitForApplied(ctx context.Context, inv *inventory.Inventory, node, digest
|
|||||||
fmt.Printf("%s applied it\n", node)
|
fmt.Printf("%s applied it\n", node)
|
||||||
return nil
|
return nil
|
||||||
case inventory.OutcomeFailed:
|
case inventory.OutcomeFailed:
|
||||||
return fmt.Errorf("%s applied what it was sent but %d resource(s) failed", node, len(doing.Failed))
|
// Each failed resource by id with the machine's own words, so the answer says what to fix
|
||||||
|
// without a second look (novox/hq issue 629).
|
||||||
|
var each []string
|
||||||
|
for _, f := range doing.Failed {
|
||||||
|
each = append(each, f.ID+": "+f.Error)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s applied what it was sent but %d resource(s) failed: %s", node, len(doing.Failed),
|
||||||
|
strings.Join(each, "; "))
|
||||||
case inventory.OutcomeRefused:
|
case inventory.OutcomeRefused:
|
||||||
return fmt.Errorf("%s refused what it was sent: %s", node, doing.Refused)
|
return fmt.Errorf("%s refused what it was sent: %s", node, doing.Refused)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/recordmirror"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The controller's record mirrors on the bus (novox/hq ADR 0289, design 54, design 57 F3): the assignments,
|
||||||
|
// and each registered module's declared config, which mesh-config resolves a module's config values from.
|
||||||
|
//
|
||||||
|
// **Built from the store, on a tick of their own, by the lease holder alone.** A record changes through a
|
||||||
|
// verb that runs as a command of its own, a reconcile, a merge's registration or a pending assignment made,
|
||||||
|
// so no one call site sees every change; what every change has in common is that it is committed to the
|
||||||
|
// store. So the serving controller reads the store every mirrorEvery and writes only what differs from what
|
||||||
|
// the bucket holds (ADR 0289 §1): a quiet mesh writes nothing, and a change rolled back is never read. A
|
||||||
|
// mirror is therefore at most mirrorEvery behind its store.
|
||||||
|
|
||||||
|
// mirrorEvery is how often the mirrors are compared with the store: two small reads of the store.
|
||||||
|
const mirrorEvery = 10 * time.Second
|
||||||
|
|
||||||
|
// mirroringRecords keeps the mirrors current for as long as this controller serves: ctx ends when it stops.
|
||||||
|
func mirroringRecords(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn, announce recordmirror.Announcer) {
|
||||||
|
api, err := jetstream.New(conn)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("the record mirrors are not kept: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
holds := func() bool {
|
||||||
|
epoch, err := theLease.epoch(ctx)
|
||||||
|
return err == nil && epoch > 0
|
||||||
|
}
|
||||||
|
epoch := func() uint64 {
|
||||||
|
e, _ := theLease.epoch(ctx)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
sources := map[string]func(context.Context) (map[string][]byte, error){
|
||||||
|
recordmirror.Assignments.Name: inv.AssignmentsMirror,
|
||||||
|
recordmirror.DeclaredConfig.Name: inv.DeclaredConfigMirror,
|
||||||
|
}
|
||||||
|
writers := map[string]*recordmirror.Writer{}
|
||||||
|
failing := map[string]string{}
|
||||||
|
tick := time.NewTicker(mirrorEvery)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
if holds() {
|
||||||
|
for _, m := range recordmirror.Mirrors() {
|
||||||
|
why := mirrorOnce(ctx, api, m, writers, sources[m.Name], announce, epoch, holds)
|
||||||
|
if why != failing[m.Name] {
|
||||||
|
if why != "" {
|
||||||
|
fmt.Printf("the %s mirror cannot be kept: %s\n", m.Name, why)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("the %s mirror is kept again\n", m.Name)
|
||||||
|
}
|
||||||
|
failing[m.Name] = why
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mirrorOnce brings one mirror to what the store holds, and answers why it could not; empty when it could.
|
||||||
|
func mirrorOnce(ctx context.Context, api jetstream.JetStream, m recordmirror.Mirror, writers map[string]*recordmirror.Writer,
|
||||||
|
source func(context.Context) (map[string][]byte, error), announce recordmirror.Announcer, epoch func() uint64,
|
||||||
|
holds func() bool) string {
|
||||||
|
reading, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
w := writers[m.Name]
|
||||||
|
if w == nil {
|
||||||
|
kv, err := api.KeyValue(reading, m.Bucket)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("the bucket %s cannot be read: %v", m.Bucket, err)
|
||||||
|
}
|
||||||
|
w = &recordmirror.Writer{Mirror: m, KV: kv, Announce: announce, Epoch: epoch, Holds: holds}
|
||||||
|
writers[m.Name] = w
|
||||||
|
}
|
||||||
|
want, err := source(reading)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("the store cannot be read: %v", err)
|
||||||
|
}
|
||||||
|
lost := w.LostEvents
|
||||||
|
r, err := w.Sync(reading, want)
|
||||||
|
if r.Written+r.Removed > 0 {
|
||||||
|
fmt.Printf("the %s mirror moved: %d written, %d removed\n", m.Name, r.Written, r.Removed)
|
||||||
|
}
|
||||||
|
if w.LostEvents > lost {
|
||||||
|
fmt.Printf("the %s mirror lost %d event(s): the bucket holds the change, its notice was refused twice\n",
|
||||||
|
m.Name, w.LostEvents-lost)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
@@ -83,6 +84,27 @@ func everyBuildKept(ctx context.Context, node string) bool {
|
|||||||
return on != "" && on == node
|
return on != "" && on == node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type keepForSettingsKey struct{}
|
||||||
|
|
||||||
|
// keepingBuildsForSettings is a context whose sends compose every module of one machine — recorded or rolling out —
|
||||||
|
// at the build that machine was last sent, as keepingEveryBuild does, for a settings walk's send (novox/hq ADR 0344):
|
||||||
|
// the layer the operator approved, and nothing of any module's new code. A build held for a person, or waiting
|
||||||
|
// for a gate, stays where it is; the walk that is its own carries it.
|
||||||
|
func keepingBuildsForSettings(ctx context.Context, node string) context.Context {
|
||||||
|
return context.WithValue(ctx, keepForSettingsKey{}, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errSettingsCannotKeep is a settings walk's send refused because a build of its machine cannot be kept: what the
|
||||||
|
// machine was last sent is not known, or a build it runs is gone from the records. It does not clear by itself, so
|
||||||
|
// the walk fails on it rather than trying again (ADR 0344).
|
||||||
|
var errSettingsCannotKeep = errors.New("the settings cannot be sent with every build kept")
|
||||||
|
|
||||||
|
// settingsKeptOn is whether this context is a settings walk's send to this machine.
|
||||||
|
func settingsKeptOn(ctx context.Context, node string) bool {
|
||||||
|
on, _ := ctx.Value(keepForSettingsKey{}).(string)
|
||||||
|
return on != "" && on == node
|
||||||
|
}
|
||||||
|
|
||||||
// recordedKept is, for a send under keepingRecorded, every recorded module the machine was last sent a
|
// recordedKept is, for a send under keepingRecorded, every recorded module the machine was last sent a
|
||||||
// build of that the mesh's build is not identical to: module → the commit it keeps. Nil when the context
|
// build of that the mesh's build is not identical to: module → the commit it keeps. Nil when the context
|
||||||
// keeps nothing, or when what the machine was last sent is not known (it is then held whole elsewhere —
|
// keeps nothing, or when what the machine was last sent is not known (it is then held whole elsewhere —
|
||||||
@@ -92,7 +114,7 @@ func everyBuildKept(ctx context.Context, node string) bool {
|
|||||||
// 490), and a machine whose last send is not known refuses the send: there is nothing to keep it at, and
|
// 490), and a machine whose last send is not known refuses the send: there is nothing to keep it at, and
|
||||||
// composing the mesh's builds would be the very move the grants step must not make.
|
// composing the mesh's builds would be the very move the grants step must not make.
|
||||||
func recordedKept(ctx context.Context, open *stores, node string) (map[string]string, error) {
|
func recordedKept(ctx context.Context, open *stores, node string) (map[string]string, error) {
|
||||||
every := everyBuildKept(ctx, node)
|
every := everyBuildKept(ctx, node) || settingsKeptOn(ctx, node)
|
||||||
skip, on := keptExcept(ctx)
|
skip, on := keptExcept(ctx)
|
||||||
if !on && !every {
|
if !on && !every {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -106,6 +128,9 @@ func recordedKept(ctx context.Context, open *stores, node string) (map[string]st
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !known {
|
if !known {
|
||||||
|
if settingsKeptOn(ctx, node) {
|
||||||
|
return nil, fmt.Errorf("%w: what %s was last sent is not known (novox/hq ADR 0344)", errSettingsCannotKeep, node)
|
||||||
|
}
|
||||||
if every {
|
if every {
|
||||||
return nil, fmt.Errorf("what %s was last sent is not known, so the bus's user list cannot be sent "+
|
return nil, fmt.Errorf("what %s was last sent is not known, so the bus's user list cannot be sent "+
|
||||||
"there alone with every build kept (novox/hq issue 490)", node)
|
"there alone with every build kept (novox/hq issue 490)", node)
|
||||||
@@ -166,6 +191,10 @@ func keepRecorded(ctx context.Context, open *stores, node string, shelf map[stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !found && settingsKeptOn(ctx, node) {
|
||||||
|
return nil, fmt.Errorf("%w: %s runs %s's build %s, which the build records no longer hold (novox/hq ADR 0344)",
|
||||||
|
errSettingsCannotKeep, node, m, short(kept[m]))
|
||||||
|
}
|
||||||
if !found && everyBuildKept(ctx, node) {
|
if !found && everyBuildKept(ctx, node) {
|
||||||
return nil, fmt.Errorf("%s runs %s's build %s, which the build records no longer hold: the bus's user "+
|
return nil, fmt.Errorf("%s runs %s's build %s, which the build records no longer hold: the bus's user "+
|
||||||
"list cannot be sent there alone with it kept (novox/hq issue 490)", node, m, short(kept[m]))
|
"list cannot be sent there alone with it kept (novox/hq issue 490)", node, m, short(kept[m]))
|
||||||
|
|||||||
+543
-39
@@ -39,8 +39,8 @@ import (
|
|||||||
// from the same manifest is no move.
|
// from the same manifest is no move.
|
||||||
//
|
//
|
||||||
// **The release plan** is what walks the waiting moves through: whenever moves wait for a gate that no
|
// **The release plan** is what walks the waiting moves through: whenever moves wait for a gate that no
|
||||||
// started plan is walking, the mesh opens one — every such machine, one at a time, the control node last,
|
// started plan is walking, the mesh opens one — every such machine, the first sent and judged, then the rest
|
||||||
// each sent and judged before the next. A release plan that fails stops the next one opening on its own:
|
// at once, each judged by its own gate (novox/hq ADR 0353, in place of one at a time). A release plan that fails stops the next one opening on its own:
|
||||||
// a person releases it again (`upgrade release-backlog --why`), after the condition says why.
|
// a person releases it again (`upgrade release-backlog --why`), after the condition says why.
|
||||||
|
|
||||||
// sendScope is what a send may carry that no gate has seen.
|
// sendScope is what a send may carry that no gate has seen.
|
||||||
@@ -81,6 +81,15 @@ type moveFacts struct {
|
|||||||
srcs map[string]map[string]string
|
srcs map[string]map[string]string
|
||||||
passed map[string]map[string]bool
|
passed map[string]map[string]bool
|
||||||
plans []inventory.Plan
|
plans []inventory.Plan
|
||||||
|
// self is the release walking these moves, whose own gates are no other walk's (novox/hq ADR 0353).
|
||||||
|
self string
|
||||||
|
}
|
||||||
|
|
||||||
|
type walkingKey struct{}
|
||||||
|
|
||||||
|
// withWalking says a release plan is the one sending: its own gates never hold its own sends back.
|
||||||
|
func withWalking(ctx context.Context, id string) context.Context {
|
||||||
|
return context.WithValue(ctx, walkingKey{}, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMoveFacts(ctx context.Context, inv *inventory.Inventory) (moveFacts, error) {
|
func readMoveFacts(ctx context.Context, inv *inventory.Inventory) (moveFacts, error) {
|
||||||
@@ -99,6 +108,7 @@ func readMoveFacts(ctx context.Context, inv *inventory.Inventory) (moveFacts, er
|
|||||||
return f, err
|
return f, err
|
||||||
}
|
}
|
||||||
f.plans, err = inv.OpenPlans(ctx)
|
f.plans, err = inv.OpenPlans(ctx)
|
||||||
|
f.self, _ = ctx.Value(walkingKey{}).(string)
|
||||||
return f, err
|
return f, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,12 +203,18 @@ func (f moveFacts) moves(node string, modules []string, sent map[string]string,
|
|||||||
// feet. A release keeps no module records: its open gate's carried moves are its walk.
|
// feet. A release keeps no module records: its open gate's carried moves are its walk.
|
||||||
func (f moveFacts) walkedBy(module, node, to string) string {
|
func (f moveFacts) walkedBy(module, node, to string) string {
|
||||||
for _, p := range f.plans {
|
for _, p := range f.plans {
|
||||||
if !p.Open() {
|
if !p.Open() || (p.Release != nil && p.ID == f.self) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if p.Release != nil {
|
if p.Release != nil {
|
||||||
g := p.Release.Gate
|
// The first machine's gate, and every gate of the rest still judging (novox/hq ADR 0353).
|
||||||
if g == nil || g.Verdict != "" {
|
gates := []*inventory.PlanGate{p.Release.Gate}
|
||||||
|
for _, g := range p.Release.Gates {
|
||||||
|
gates = append(gates, g)
|
||||||
|
}
|
||||||
|
for _, g := range gates {
|
||||||
|
// A send kept and not yet made walks nothing yet (ADR 0353 §8).
|
||||||
|
if g == nil || g.Verdict != "" || g.Silent || g.Pending {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, c := range g.Carried {
|
for _, c := range g.Carried {
|
||||||
@@ -206,6 +222,7 @@ func (f moveFacts) walkedBy(module, node, to string) string {
|
|||||||
return p.ID
|
return p.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s, holds := p.Modules[module]
|
s, holds := p.Modules[module]
|
||||||
@@ -303,8 +320,8 @@ func ungatedIn(ctx context.Context, open *stores, names []string, addedHolder st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(refused) > 0 {
|
if len(refused) > 0 {
|
||||||
return nil, fmt.Errorf("%w: %s — a walk sends them, one machine at a time, each judged (novox/hq ADR "+
|
return nil, fmt.Errorf("%w: %s — a walk sends them, one machine first and then the rest at once, each judged (novox/hq ADR "+
|
||||||
"0236); `upgrade backlog` lists them", errUngated, strings.Join(refused, "; "))
|
"0236, ADR 0353); `upgrade backlog` lists them", errUngated, strings.Join(refused, "; "))
|
||||||
}
|
}
|
||||||
return kept, nil
|
return kept, nil
|
||||||
}
|
}
|
||||||
@@ -320,19 +337,35 @@ func ungatedIn(ctx context.Context, open *stores, names []string, addedHolder st
|
|||||||
// And the grants step first, when the send changes what the bus's user list must say (novox/hq issue 490):
|
// And the grants step first, when the send changes what the bus's user list must say (novox/hq issue 490):
|
||||||
// answered for the gate to keep, nil when there was none.
|
// answered for the gate to keep, nil when there was none.
|
||||||
func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.CarriedMove) ([]inventory.CarriedMove, []string, *inventory.GrantsStep, error) {
|
func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.CarriedMove) ([]inventory.CarriedMove, []string, *inventory.GrantsStep, error) {
|
||||||
inv := open.inventory
|
moves, err := gatedMoves(ctx, open, node, owns)
|
||||||
f, err := readMoveFacts(ctx, inv)
|
if err != nil || len(moves) == 0 {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
sayRecreations(ctx, open, moves)
|
||||||
|
sent, grants, err := sendJudged(ctx, open, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
return moves, sent, grants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatedMoves is what a gated send to one machine would carry, with the build each moves to, made of nothing:
|
||||||
|
// gatedSend's reading, which a walk also keeps before it sends (novox/hq ADR 0353 §8). Refused the same way
|
||||||
|
// while a plan that has started walks one of those builds elsewhere.
|
||||||
|
func gatedMoves(ctx context.Context, open *stores, node string, owns []inventory.CarriedMove) ([]inventory.CarriedMove, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
f, err := readMoveFacts(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
moves, err := machineMoves(ctx, open, f, node, true)
|
moves, err := machineMoves(ctx, open, f, node, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// A move the send keeps is not made, and is no move for the gate to judge (novox/hq issue 201): a core module
|
// A move the send keeps is not made, and is no move for the gate to judge (novox/hq issue 201): a core module
|
||||||
// kept over an older record judged as moving back to it would read the gate as superseded by its own send.
|
// kept over an older record judged as moving back to it would read the gate as superseded by its own send.
|
||||||
if moves, err = withoutKept(ctx, open, node, moves); err != nil {
|
if moves, err = withoutKept(ctx, open, node, moves); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
own := func(module string) bool {
|
own := func(module string) bool {
|
||||||
return slices.ContainsFunc(owns, func(o inventory.CarriedMove) bool { return o.Module == module })
|
return slices.ContainsFunc(owns, func(o inventory.CarriedMove) bool { return o.Module == module })
|
||||||
@@ -342,7 +375,7 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if id := f.walkedBy(mv.Module, node, mv.To); id != "" {
|
if id := f.walkedBy(mv.Module, node, mv.To); id != "" {
|
||||||
return nil, nil, nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
return nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
||||||
mv.Module, short(mv.To), node, id)
|
mv.Module, short(mv.To), node, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,7 +383,7 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.
|
|||||||
// plan, or a release, on this machine is not crossed with a newer build; this send waits for its gate.
|
// plan, or a release, on this machine is not crossed with a newer build; this send waits for its gate.
|
||||||
for _, o := range owns {
|
for _, o := range owns {
|
||||||
if id := f.walkedBy(o.Module, node, o.To); id != "" {
|
if id := f.walkedBy(o.Module, node, o.To); id != "" {
|
||||||
return nil, nil, nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
return nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
||||||
o.Module, short(o.To), node, id)
|
o.Module, short(o.To), node, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,30 +396,26 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.
|
|||||||
moves[i].Build = o.Build
|
moves[i].Build = o.Build
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(moves) == 0 && len(owns) == 0 {
|
if len(moves) == 0 {
|
||||||
return nil, nil, nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
for i := range moves {
|
for i := range moves {
|
||||||
if moves[i].Build == "" {
|
if moves[i].Build == "" {
|
||||||
if moves[i].Build, err = inv.BuildOf(ctx, moves[i].Module, moves[i].To); err != nil {
|
if moves[i].Build, err = inv.BuildOf(ctx, moves[i].Module, moves[i].To); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(moves, func(i, j int) bool { return moves[i].Module < moves[j].Module })
|
sort.Slice(moves, func(i, j int) bool { return moves[i].Module < moves[j].Module })
|
||||||
sayRecreations(ctx, open, moves)
|
return moves, nil
|
||||||
sent, grants, err := sendJudged(ctx, open, node)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, nil, err
|
|
||||||
}
|
|
||||||
return moves, sent, grants, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// passCarried keeps a pass as the verdict of every build the gate judged beside its own module.
|
// passCarried keeps a pass as the verdict of every build the gate judged beside its own module.
|
||||||
func passCarried(ctx context.Context, open *stores, p *inventory.Plan, g *inventory.PlanGate, except string) {
|
func passCarried(ctx context.Context, open *stores, p *inventory.Plan, g *inventory.PlanGate, except string) {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, c := range g.Carried {
|
for _, c := range g.Carried {
|
||||||
if c.Module == except || c.Build == "" || seen[c.Build] {
|
// A build put back with another machine's failure is not this gate's to pass (novox/hq ADR 0353 §3).
|
||||||
|
if c.Module == except || c.Build == "" || seen[c.Build] || slices.Contains(g.Returned, c.Module) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[c.Build] = true
|
seen[c.Build] = true
|
||||||
@@ -616,6 +645,10 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
|
|||||||
}
|
}
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
if p.Release != nil {
|
if p.Release != nil {
|
||||||
|
if by != "" && p.Release.Settings != nil {
|
||||||
|
return nil, fmt.Errorf("%s is sending %s; what waits is released once it ends — `plans %s` says where it is",
|
||||||
|
p.ID, p.Repository, p.ID)
|
||||||
|
}
|
||||||
if by != "" {
|
if by != "" {
|
||||||
return nil, fmt.Errorf("%s is already releasing what waits; `plans %s` says where it is", p.ID, p.ID)
|
return nil, fmt.Errorf("%s is already releasing what waits; `plans %s` says where it is", p.ID, p.ID)
|
||||||
}
|
}
|
||||||
@@ -641,7 +674,9 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, p := range recent {
|
for _, p := range recent {
|
||||||
if p.Release == nil {
|
// A settings walk that failed holds nothing back (novox/hq ADR 0344 §6): its failure is its module's
|
||||||
|
// settings on one machine, said on its own, not a build no gate passed.
|
||||||
|
if p.Release == nil || p.Release.Settings != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if p.State == inventory.PlanFailed {
|
if p.State == inventory.PlanFailed {
|
||||||
@@ -689,7 +724,7 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
|
|||||||
p := inventory.Plan{ID: fmt.Sprintf("release-%d", now.UnixNano()), Repository: releaseRepository,
|
p := inventory.Plan{ID: fmt.Sprintf("release-%d", now.UnixNano()), Repository: releaseRepository,
|
||||||
Created: now, State: inventory.PlanRolling, Tiers: [][]string{names}, Modules: map[string]*inventory.PlanModule{},
|
Created: now, State: inventory.PlanRolling, Tiers: [][]string{names}, Modules: map[string]*inventory.PlanModule{},
|
||||||
Release: &inventory.PlanRelease{Order: order, By: by},
|
Release: &inventory.PlanRelease{Order: order, By: by},
|
||||||
Note: fmt.Sprintf("%d build(s) wait for a gate on %s; one machine at a time, each judged", len(names),
|
Note: fmt.Sprintf("%d build(s) wait for a gate on %s; one machine first, then the rest at once, each judged", len(names),
|
||||||
strings.Join(order, ", "))}
|
strings.Join(order, ", "))}
|
||||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -698,11 +733,23 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// advanceRelease takes one step of a release plan: the next machine sent everything waiting there under a
|
// advanceRelease takes one step of a release plan: the first machine sent everything waiting there under a
|
||||||
// gate, or the machine being judged judged once more; the plan done when every machine is.
|
// gate and judged; once it passed, every other machine sent at once, each under its own gate (novox/hq ADR
|
||||||
|
// 0353 §1), and judged; the plan done when every machine is.
|
||||||
|
//
|
||||||
|
// **A send is kept before it is made** (ADR 0353 §8): the step that chooses a machine keeps its gate pending with
|
||||||
|
// what it will carry, and the next step makes the send. A controller replaced between the send and keeping it
|
||||||
|
// finds the gate pending, reads the machine's last send, and neither sends again nor leaves the machine unjudged.
|
||||||
func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) {
|
func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) {
|
||||||
r := p.Release
|
r := p.Release
|
||||||
|
if r.Settings != nil {
|
||||||
|
return advanceSettingsWalk(ctx, open, p)
|
||||||
|
}
|
||||||
|
ctx = withWalking(ctx, p.ID)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
if r.Rest {
|
||||||
|
return advanceRest(ctx, open, p, now)
|
||||||
|
}
|
||||||
if r.Gate == nil {
|
if r.Gate == nil {
|
||||||
heard, err := releaseHeard(ctx, open)
|
heard, err := releaseHeard(ctx, open)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -715,7 +762,7 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
|||||||
r.Next++
|
r.Next++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
moves, sent, grants, err := gatedSend(ctx, open, node, nil)
|
moves, err := gatedMoves(ctx, open, node, nil)
|
||||||
if errors.Is(err, errWalkedElsewhere) {
|
if errors.Is(err, errWalkedElsewhere) {
|
||||||
note := fmt.Sprintf("waiting before %s: %v", node, err)
|
note := fmt.Sprintf("waiting before %s: %v", node, err)
|
||||||
changed := p.Note != note
|
changed := p.Note != note
|
||||||
@@ -723,23 +770,15 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
|||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("sending %s what waits there: %w", node, err)
|
return false, fmt.Errorf("reading what waits on %s: %w", node, err)
|
||||||
}
|
}
|
||||||
if len(moves) == 0 {
|
if len(moves) == 0 {
|
||||||
r.Done = append(r.Done, node)
|
r.Done = append(r.Done, node)
|
||||||
r.Next++
|
r.Next++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r.Gate = &inventory.PlanGate{Machines: sent, Since: &now, Carried: moves, Sent: sentNow(ctx, open.inventory, sent),
|
r.Gate = &inventory.PlanGate{Machines: []string{node}, Carried: moves, Pending: true}
|
||||||
Grants: grants}
|
p.Note = fmt.Sprintf("sending %s %d build(s) that wait for a gate, first", node, len(moves))
|
||||||
p.Note = fmt.Sprintf("sent %s %d build(s) that waited for a gate; judging them there", node, len(moves))
|
|
||||||
if said := grantsSaid(grants); said != "" {
|
|
||||||
p.Note += "; " + said
|
|
||||||
}
|
|
||||||
if said := recreationsSaid(moves); said != "" {
|
|
||||||
p.Note += "; " + said
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
p.State, p.Tier = inventory.PlanDone, len(p.Tiers)
|
p.State, p.Tier = inventory.PlanDone, len(p.Tiers)
|
||||||
@@ -750,6 +789,19 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
g := r.Gate
|
g := r.Gate
|
||||||
|
if g.Pending {
|
||||||
|
node := firstOf(g.Machines)
|
||||||
|
out, err := sendKept(ctx, open, p, node, g, now)
|
||||||
|
if err != nil || out == keptWaiting {
|
||||||
|
return out == keptWaiting, err
|
||||||
|
}
|
||||||
|
if out == keptNothing {
|
||||||
|
r.Gate = nil
|
||||||
|
r.Done = append(r.Done, node)
|
||||||
|
r.Next++
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
verdict, err := judgeMoves(ctx, open, g, nil, now)
|
verdict, err := judgeMoves(ctx, open, g, nil, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -768,6 +820,11 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
|||||||
r.Done = append(r.Done, firstOf(g.Machines))
|
r.Done = append(r.Done, firstOf(g.Machines))
|
||||||
r.Next++
|
r.Next++
|
||||||
r.Gate = nil
|
r.Gate = nil
|
||||||
|
// **Then the rest at once** (novox/hq ADR 0353 §1): every other machine heard from, each judged by its own
|
||||||
|
// gate, in place of one machine at a time (ADR 0236 §22).
|
||||||
|
r.Rest = true
|
||||||
|
p.Note = fmt.Sprintf("passed on %s; the rest are sent at once, each judged", firstOf(g.Machines))
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
return true, nil
|
return true, nil
|
||||||
case inventory.GateSuperseded:
|
case inventory.GateSuperseded:
|
||||||
// Another send moved a judged module on the judged machine (novox/hq issue 352): no verdict on what
|
// Another send moved a judged module on the judged machine (novox/hq issue 352): no verdict on what
|
||||||
@@ -788,6 +845,453 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What sendKept did with a kept send.
|
||||||
|
const (
|
||||||
|
keptSent = iota // sent now
|
||||||
|
keptAlready // the machine was already sent it: judged from that send, nothing sent
|
||||||
|
keptNothing // nothing waits there any more: no gate
|
||||||
|
keptWaiting // another walk judges a build it would carry: it waits
|
||||||
|
)
|
||||||
|
|
||||||
|
// sendKept makes a send a gate kept before it was made (novox/hq ADR 0353 §8) — unless the machine's last send
|
||||||
|
// already carries what the gate is for, made before a controller was replaced and before it was kept: then
|
||||||
|
// nothing is sent, and the gate judges from that send (§6).
|
||||||
|
func sendKept(ctx context.Context, open *stores, p *inventory.Plan, node string, g *inventory.PlanGate,
|
||||||
|
now time.Time) (int, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
moves, sent, grants, err := gatedSend(ctx, open, node, nil)
|
||||||
|
if errors.Is(err, errWalkedElsewhere) {
|
||||||
|
p.Note = fmt.Sprintf("waiting before %s: %v", node, err)
|
||||||
|
return keptWaiting, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return keptSent, fmt.Errorf("sending %s what waits there: %w", node, err)
|
||||||
|
}
|
||||||
|
if len(moves) > 0 {
|
||||||
|
g.Machines, g.Carried, g.Since, g.Grants, g.Pending = sent, moves, &now, grants, false
|
||||||
|
g.Sent = sentNow(ctx, inv, sent)
|
||||||
|
p.Note = fmt.Sprintf("sent %s %d build(s) that waited for a gate; judging them there", node, len(moves))
|
||||||
|
if said := grantsSaid(grants); said != "" {
|
||||||
|
p.Note += "; " + said
|
||||||
|
}
|
||||||
|
if said := recreationsSaid(moves); said != "" {
|
||||||
|
p.Note += "; " + said
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
return keptSent, nil
|
||||||
|
}
|
||||||
|
if !alreadySent(ctx, inv, node, g.Carried) {
|
||||||
|
return keptNothing, nil
|
||||||
|
}
|
||||||
|
since := now
|
||||||
|
if s, found, err := inv.SentTo(ctx, node); err == nil && found {
|
||||||
|
if !s.At.IsZero() {
|
||||||
|
since = s.At
|
||||||
|
}
|
||||||
|
g.Sent = map[string]inventory.SentDeclaration{node: s}
|
||||||
|
}
|
||||||
|
g.Since, g.Pending = &since, false
|
||||||
|
p.Note = fmt.Sprintf("%s was already sent the %d build(s) kept for it, at %s: judged from that send, nothing sent again",
|
||||||
|
node, len(g.Carried), since.Local().Format("15:04:05"))
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
return keptAlready, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// alreadySent says a machine's last send carries every build of the moves: a send made and not kept.
|
||||||
|
func alreadySent(ctx context.Context, inv *inventory.Inventory, node string, moves []inventory.CarriedMove) bool {
|
||||||
|
if len(moves) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sent, known, err := inv.SentBuilds(ctx, node)
|
||||||
|
if err != nil || !known {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, mv := range moves {
|
||||||
|
if !sameCommit(sent[mv.Module], mv.To) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceRest takes one step of a release plan's rest (novox/hq ADR 0353): the sends kept for a step made, every
|
||||||
|
// machine of the step judged by its own gate, one that fails put back by itself while the others go on to their
|
||||||
|
// verdicts, and the next step — or the end — once every gate of the step has its verdict or is silent.
|
||||||
|
func advanceRest(ctx context.Context, open *stores, p *inventory.Plan, now time.Time) (bool, error) {
|
||||||
|
r := p.Release
|
||||||
|
// The sends kept for this step, in the plan's order: the control node, last in it, last (§1).
|
||||||
|
var pending []string
|
||||||
|
for _, n := range r.Order {
|
||||||
|
if g := r.Gates[n]; g != nil && g.Pending {
|
||||||
|
pending = append(pending, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pending) > 0 {
|
||||||
|
// **A kept send carries only what was kept** (ADR 0353 §2): a build that passed no gate and came to wait on
|
||||||
|
// a machine since its step was chosen could reach a second machine in it; the step is chosen again.
|
||||||
|
if grew, err := keptStepGrew(ctx, open, r, pending); err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if grew != "" {
|
||||||
|
for _, n := range pending {
|
||||||
|
delete(r.Gates, n)
|
||||||
|
}
|
||||||
|
opened, waiting, err := openRestStep(ctx, open, p)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
p.Note = "chosen again: " + grew
|
||||||
|
if !opened && waiting != "" {
|
||||||
|
p.Note += "; " + waiting
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var sent, already, waiting []string
|
||||||
|
for _, node := range pending {
|
||||||
|
out, err := sendKept(ctx, open, p, node, r.Gates[node], now)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
switch out {
|
||||||
|
case keptSent:
|
||||||
|
sent = append(sent, node)
|
||||||
|
case keptAlready:
|
||||||
|
already = append(already, node)
|
||||||
|
case keptNothing:
|
||||||
|
delete(r.Gates, node)
|
||||||
|
r.Done = append(r.Done, node)
|
||||||
|
case keptWaiting:
|
||||||
|
waiting = append(waiting, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var said []string
|
||||||
|
if len(sent) > 0 {
|
||||||
|
said = append(said, "sent "+strings.Join(sent, ", ")+" at once, each judged there")
|
||||||
|
}
|
||||||
|
if len(already) > 0 {
|
||||||
|
said = append(said, strings.Join(already, ", ")+" already sent it: judged from that send")
|
||||||
|
}
|
||||||
|
if len(waiting) > 0 {
|
||||||
|
said = append(said, "waiting before "+strings.Join(waiting, ", ")+": another walk judges a build it carries")
|
||||||
|
}
|
||||||
|
if len(said) > 0 {
|
||||||
|
p.Note = strings.Join(said, "; ")
|
||||||
|
}
|
||||||
|
return len(sent)+len(already) > 0 || len(waiting) < len(pending), nil
|
||||||
|
}
|
||||||
|
reports, err := open.inventory.LastReports(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
byNode := map[string]inventory.Reported{}
|
||||||
|
for _, rep := range reports {
|
||||||
|
byNode[rep.Node] = rep
|
||||||
|
}
|
||||||
|
changed, noteWas := false, p.Note
|
||||||
|
var notes, judging []string
|
||||||
|
batched, back := batchingRollbacks(ctx)
|
||||||
|
for _, node := range r.Order {
|
||||||
|
g := r.Gates[node]
|
||||||
|
if g == nil || g.Verdict != "" || g.Silent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// What another machine's failure already put back is not this gate's to judge (ADR 0353 §3).
|
||||||
|
if len(g.Carried) > 0 && len(g.Returned) >= len(g.Carried) {
|
||||||
|
decide(g, inventory.GateFailed, "what it was sent was put back with another machine's failure", now)
|
||||||
|
r.Failed = append(r.Failed, node)
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// **A machine that says nothing on its send is left silent, not failed** (ADR 0353 §4): it is not judged
|
||||||
|
// until it reports, and past ApplySilentAfter it is listed and left.
|
||||||
|
if !reportedOnSend(byNode[node], node, g) {
|
||||||
|
if now.Sub(*g.Since) > inventory.ApplySilentAfter {
|
||||||
|
g.Silent = true
|
||||||
|
r.Silent = append(r.Silent, node)
|
||||||
|
changed = true
|
||||||
|
notes = append(notes, fmt.Sprintf("%s said nothing on its send within %s: left silent", node,
|
||||||
|
inventory.ApplySilentAfter))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
judging = append(judging, node+": not yet reported on its send")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
verdict, err := judgeMoves(ctx, open, g, nil, now)
|
||||||
|
if err != nil {
|
||||||
|
return changed, err
|
||||||
|
}
|
||||||
|
switch verdict {
|
||||||
|
case "":
|
||||||
|
if putBackBroken(ctx, open, p, g, "", nil) {
|
||||||
|
changed = true
|
||||||
|
notes = append(notes, p.Note)
|
||||||
|
}
|
||||||
|
judging = append(judging, node+": "+gateLine(g))
|
||||||
|
case inventory.GatePassed:
|
||||||
|
passCarried(ctx, open, p, g, "")
|
||||||
|
r.Done = append(r.Done, node)
|
||||||
|
changed = true
|
||||||
|
case inventory.GateSuperseded:
|
||||||
|
// Another send moved a judged module there (novox/hq issue 352): nothing judged, nothing put back on
|
||||||
|
// it; what still waits is released again.
|
||||||
|
r.Done = append(r.Done, node)
|
||||||
|
changed = true
|
||||||
|
notes = append(notes, fmt.Sprintf("superseded on %s: %s", node, g.Why))
|
||||||
|
default:
|
||||||
|
// **Put back by itself** (ADR 0353 §3): what was found wanting goes back at once, and every other
|
||||||
|
// machine of the step goes on to its own verdict.
|
||||||
|
stateWas := p.State
|
||||||
|
p.Note = ""
|
||||||
|
failCarried(batched, open, p, g, "")
|
||||||
|
p.State = stateWas
|
||||||
|
r.Failed = append(r.Failed, node)
|
||||||
|
changed = true
|
||||||
|
notes = append(notes, fmt.Sprintf("failed its gate on %s: %s — %s", node, g.Why, p.Note))
|
||||||
|
returnedElsewhere(ctx, open, r, node, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Note = ""
|
||||||
|
sendRollbacks(ctx, open, p, back)
|
||||||
|
if p.Note != "" {
|
||||||
|
notes = append(notes, strings.TrimPrefix(p.Note, "; "))
|
||||||
|
}
|
||||||
|
if len(judging) > 0 {
|
||||||
|
note := strings.Join(append(notes, "judging "+strings.Join(judging, "; ")), "; ")
|
||||||
|
p.Note = note
|
||||||
|
return changed || noteWas != note, nil
|
||||||
|
}
|
||||||
|
// Every gate of the step has its verdict or is silent: the next step, unless a machine failed (§3).
|
||||||
|
if len(r.Failed) == 0 {
|
||||||
|
opened, waiting, err := openRestStep(ctx, open, p)
|
||||||
|
if err != nil {
|
||||||
|
return changed, err
|
||||||
|
}
|
||||||
|
if opened {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if waiting != "" {
|
||||||
|
note := strings.Join(append(notes, waiting), "; ")
|
||||||
|
p.Note = note
|
||||||
|
return changed || noteWas != note, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Tier = len(p.Tiers)
|
||||||
|
end := fmt.Sprintf("released on %s", orNone(strings.Join(r.Done, ", ")))
|
||||||
|
if len(r.Silent) > 0 {
|
||||||
|
end += "; silent on their send, left: " + strings.Join(r.Silent, ", ")
|
||||||
|
}
|
||||||
|
if len(r.Skipped) > 0 {
|
||||||
|
end += "; not heard from, left as they were: " + strings.Join(r.Skipped, ", ")
|
||||||
|
}
|
||||||
|
p.State = inventory.PlanDone
|
||||||
|
if len(r.Failed) > 0 {
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
end = "failed on " + strings.Join(r.Failed, ", ") + "; " + end
|
||||||
|
} else if len(r.Silent) > 0 && len(r.Gates) == len(r.Silent) {
|
||||||
|
// **A rest that said nothing is no success** (ADR 0353 §4): failure is loud; the condition says it.
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
end = "no machine of the rest reported on its send; " + end
|
||||||
|
}
|
||||||
|
p.Note = strings.Join(append(notes, end), "; ")
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keptStepGrew says why a step's kept sends would now carry a build no gate passed that they were not kept with;
|
||||||
|
// empty when none would.
|
||||||
|
func keptStepGrew(ctx context.Context, open *stores, r *inventory.PlanRelease, pending []string) (string, error) {
|
||||||
|
f, err := readMoveFacts(ctx, open.inventory)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, node := range pending {
|
||||||
|
moves, err := gatedMoves(ctx, open, node, nil)
|
||||||
|
if errors.Is(err, errWalkedElsewhere) {
|
||||||
|
continue // sendKept says it waits
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
kept := r.Gates[node].Carried
|
||||||
|
for _, mv := range moves {
|
||||||
|
// The node-engine and the resolver keep one machine at a time, passed or not (ADR 0353 §9).
|
||||||
|
if (f.gated(mv.Module, mv.To) && !oneAtATime(mv.Module, shelf[mv.Module])) || slices.ContainsFunc(kept, func(c inventory.CarriedMove) bool {
|
||||||
|
return c.Module == mv.Module && sameCommit(c.To, mv.To)
|
||||||
|
}) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s's build %s, which this step did not keep for it, came to wait on %s", mv.Module, short(mv.To), node), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportedOnSend says a machine's last report is about the send its gate judges (novox/hq issue 352): the
|
||||||
|
// declaration kept on the gate, or one sequenced after it; a gate that kept none reads the send made last.
|
||||||
|
func reportedOnSend(r inventory.Reported, node string, g *inventory.PlanGate) bool {
|
||||||
|
if r.At == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s, kept := g.Sent[node]; kept {
|
||||||
|
return s.ReportsOn(r)
|
||||||
|
}
|
||||||
|
return r.Current
|
||||||
|
}
|
||||||
|
|
||||||
|
// returnedElsewhere marks, on every other gate of the rest still judging, the builds a failed machine's
|
||||||
|
// rollback put back wherever they were sent (ADR 0236 §5): no longer theirs to judge (ADR 0353 §3).
|
||||||
|
func returnedElsewhere(ctx context.Context, open *stores, r *inventory.PlanRelease, failed string, g *inventory.PlanGate) {
|
||||||
|
for _, c := range g.Carried {
|
||||||
|
if c.Build == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if marked, err := open.inventory.GateFailed(ctx, c.Build); err != nil || !marked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for node, other := range r.Gates {
|
||||||
|
if node == failed || other.Verdict != "" || slices.Contains(other.Returned, c.Module) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, oc := range other.Carried {
|
||||||
|
if oc.Module == c.Module && sameCommit(oc.To, c.To) {
|
||||||
|
other.Returned = append(other.Returned, c.Module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openRestStep chooses the next step of a release plan's rest and keeps its sends (novox/hq ADR 0353 §1, §2):
|
||||||
|
// every machine left that is heard from and has something waiting, except that a build no gate has passed goes
|
||||||
|
// to one machine of the step only. Answers whether a step was kept, or why the walk waits.
|
||||||
|
func openRestStep(ctx context.Context, open *stores, p *inventory.Plan) (bool, string, error) {
|
||||||
|
r := p.Release
|
||||||
|
heard, err := releaseHeard(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
f, err := readMoveFacts(ctx, open.inventory)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
waiting := map[string][]inventory.CarriedMove{}
|
||||||
|
var nodes, walked []string
|
||||||
|
for _, node := range r.Order[min(r.Next, len(r.Order)):] {
|
||||||
|
if r.Gates[node] != nil || slices.Contains(r.Done, node) || slices.Contains(r.Skipped, node) ||
|
||||||
|
slices.Contains(r.Failed, node) || slices.Contains(r.Silent, node) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !heard[node] {
|
||||||
|
r.Skipped = append(r.Skipped, node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moves, err := gatedMoves(ctx, open, node, nil)
|
||||||
|
if errors.Is(err, errWalkedElsewhere) {
|
||||||
|
walked = append(walked, fmt.Sprintf("%s (%v)", node, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, "", fmt.Errorf("reading what waits on %s: %w", node, err)
|
||||||
|
}
|
||||||
|
if len(moves) == 0 {
|
||||||
|
r.Done = append(r.Done, node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
waiting[node] = moves
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
shelf, err := open.inventory.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
step := restStep(nodes, waiting, f.gated, func(module string) bool { return oneAtATime(module, shelf[module]) })
|
||||||
|
if len(step) == 0 {
|
||||||
|
if len(walked) > 0 {
|
||||||
|
return false, "waiting before " + strings.Join(walked, "; "), nil
|
||||||
|
}
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
if r.Gates == nil {
|
||||||
|
r.Gates = map[string]*inventory.PlanGate{}
|
||||||
|
}
|
||||||
|
for _, node := range step {
|
||||||
|
r.Gates[node] = &inventory.PlanGate{Machines: []string{node}, Carried: waiting[node], Pending: true}
|
||||||
|
}
|
||||||
|
p.Note = fmt.Sprintf("sending %s at once, each judged there", strings.Join(step, ", "))
|
||||||
|
if left := len(nodes) - len(step); left > 0 {
|
||||||
|
p.Note += fmt.Sprintf("; %d machine(s) wait for a build that passed no gate yet to pass on one", left)
|
||||||
|
}
|
||||||
|
return true, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneAtATime is a module whose builds go to the rest one machine at a time (novox/hq ADR 0353 §9): the
|
||||||
|
// node-engine, which applies every send, and a module holding the mesh's resolver, which names the bus.
|
||||||
|
func oneAtATime(module string, m catalogue.Manifest) bool {
|
||||||
|
if module == hostModule {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(m.Claims, func(c catalogue.Claim) bool { return c.Name == resolverSeat })
|
||||||
|
}
|
||||||
|
|
||||||
|
// restStep is the machines of one step of the rest (novox/hq ADR 0353 §2), in the order given: each machine, unless
|
||||||
|
// a build it would carry has passed no gate and an earlier machine of the step already carries it — that build's
|
||||||
|
// first machine. Pure.
|
||||||
|
//
|
||||||
|
// **The node-engine and the mesh's resolver still go one machine at a time** (ADR 0353 §9): a step carries a
|
||||||
|
// build of either to one machine only, judged before the next, since a fault that cuts a machine off the bus
|
||||||
|
// shows only as its silence and no rollback reaches it.
|
||||||
|
func restStep(nodes []string, waiting map[string][]inventory.CarriedMove, passed func(module, commit string) bool,
|
||||||
|
alone func(module string) bool) []string {
|
||||||
|
first := map[string]string{}
|
||||||
|
var out []string
|
||||||
|
for _, n := range nodes {
|
||||||
|
var unpassed []string
|
||||||
|
for _, mv := range waiting[n] {
|
||||||
|
if !passed(mv.Module, mv.To) || alone(mv.Module) {
|
||||||
|
unpassed = append(unpassed, mv.Module+"@"+mv.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slices.ContainsFunc(unpassed, func(k string) bool { _, taken := first[k]; return taken }) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, k := range unpassed {
|
||||||
|
first[k] = n
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// silentObservation is what the gate's probe says of the newest release whose rest went silent (novox/hq ADR 0353
|
||||||
|
// §4): machines that never reported on their send, left as they were — loud, never a success said by nothing.
|
||||||
|
func silentObservation(ctx context.Context, inv *inventory.Inventory) ([]conditions.Observation, error) {
|
||||||
|
recent, err := inv.RecentPlans(ctx, 20)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range recent {
|
||||||
|
if p.Release == nil || p.Release.Settings != nil || p.Open() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(p.Release.Silent) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
machines := append([]string(nil), p.Release.Silent...)
|
||||||
|
sort.Strings(machines)
|
||||||
|
o := conditions.Observation{Scope: conditions.ScopeMesh, ID: "release", Token: "silent", Kind: "release-silent",
|
||||||
|
Severity: conditions.Warning, Resolver: conditions.ResolverOperator, Also: machines,
|
||||||
|
Summary: fmt.Sprintf("%s: %s said nothing on the send of the builds that waited for a gate within %s, and were "+
|
||||||
|
"left as they were", p.ID, strings.Join(machines, ", "), inventory.ApplySilentAfter)}
|
||||||
|
w := releaseSilentWords(machines)
|
||||||
|
o.Headline, o.Explanation, o.Resolved, o.Needs = w.Headline, w.Explanation, w.Resolved, w.Needs
|
||||||
|
return []conditions.Observation{o}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// backlogObservation is what the gate's probe says of a release held after a failure.
|
// backlogObservation is what the gate's probe says of a release held after a failure.
|
||||||
func backlogObservation() []conditions.Observation {
|
func backlogObservation() []conditions.Observation {
|
||||||
if backlogNow.held == "" || len(backlogNow.waiting) == 0 {
|
if backlogNow.held == "" || len(backlogNow.waiting) == 0 {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/builder"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
)
|
)
|
||||||
@@ -250,6 +251,20 @@ func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool)
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noMoveWhy is what a plan says of a module of its tier whose build was made from the source every machine
|
||||||
|
// running it runs (novox/hq issue 280): nothing is sent for it.
|
||||||
|
// noMovePrefix opens it, and is what noMove reads: one prefix for the writer and the reader.
|
||||||
|
const (
|
||||||
|
noMovePrefix = "no move"
|
||||||
|
noMoveWhy = noMovePrefix + ": made from the source every machine running it runs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// noMove is whether the plan marked the module no move and sent it nothing. Read from what the plan says,
|
||||||
|
// so a plan kept before the mark had a name of its own is read the same (novox/hq issue 648).
|
||||||
|
func noMove(s *inventory.PlanModule) bool {
|
||||||
|
return s != nil && s.FirstAt == nil && s.Gate == nil && strings.HasPrefix(s.Why, noMovePrefix)
|
||||||
|
}
|
||||||
|
|
||||||
// applied says whether every machine running the module has reported since the module was built.
|
// applied says whether every machine running the module has reported since the module was built.
|
||||||
// appliedEach is applied with a moment of its own for each machine: the reports that count are the ones
|
// appliedEach is applied with a moment of its own for each machine: the reports that count are the ones
|
||||||
// after that machine was sent the build (novox/hq issue 256).
|
// after that machine was sent the build (novox/hq issue 256).
|
||||||
@@ -313,6 +328,86 @@ func init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reuseKey carries a walk's leave to take a check's build from askModule to the ask it makes (novox/hq ADR 0350 §2).
|
||||||
|
type reuseKey struct{}
|
||||||
|
|
||||||
|
// withReuse is ctx carrying a walk's leave to take a check's build.
|
||||||
|
func withReuse(ctx context.Context, r *link.ReuseAsk) context.Context {
|
||||||
|
return context.WithValue(ctx, reuseKey{}, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reuseIn is the leave ctx carries; nil for every ask but a walk's.
|
||||||
|
func reuseIn(ctx context.Context) *link.ReuseAsk {
|
||||||
|
r, _ := ctx.Value(reuseKey{}).(*link.ReuseAsk)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// reuseAsked is the leave an ask carries: a walk's, and never a dry run's — a dry run registers nothing, so it
|
||||||
|
// takes nothing either (novox/hq ADR 0350 §2, §6).
|
||||||
|
func reuseAsked(ctx context.Context, dryRun bool) *link.ReuseAsk {
|
||||||
|
if dryRun {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return reuseIn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reuseAskOf is a walk's leave to take a check's build: the pull requests of the merges it answers.
|
||||||
|
func reuseAskOf(p *inventory.Plan) *link.ReuseAsk {
|
||||||
|
r := &link.ReuseAsk{}
|
||||||
|
if p.Delivery == nil {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
seen := map[link.PullRef]bool{}
|
||||||
|
for _, m := range p.Delivery.Merges {
|
||||||
|
pull, ok := pullOf(m)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !seen[pull] {
|
||||||
|
seen[pull] = true
|
||||||
|
r.Pulls = append(r.Pulls, pull)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkedBuildsOf are the builds the build seat's checks of those pull requests said they made for their merges,
|
||||||
|
// within the week check builds are kept (novox/hq ADR 0350 §2): read from the seat's own outcomes on the bus. A
|
||||||
|
// variable so a test needs no bus.
|
||||||
|
var checkedBuildsOf = func(ctx context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
|
||||||
|
if len(pulls) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
js, err := aBus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
return link.ReadCheckBuilds(ctx, js, buildSeatHeld(ctx), pulls, time.Now().Add(-builder.CheckBuildsKept))
|
||||||
|
}
|
||||||
|
|
||||||
|
// reuseWithVerdicts is a walk's leave with the builds its pull requests' verdicts name: the only builds the builder
|
||||||
|
// may take (ADR 0350 §2). When they cannot be read the leave names none, and the walk builds, saying why.
|
||||||
|
func reuseWithVerdicts(ctx context.Context, r *link.ReuseAsk) *link.ReuseAsk {
|
||||||
|
builds, err := checkedBuildsOf(ctx, r.Pulls)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" the checks' builds of %d pull request(s) could not be read, so the walk builds: %v\n", len(r.Pulls), err)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
r.Builds = builds
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// pullOf is a merge's pull request: owner and repository, the last two parts of however the repository is spelled
|
||||||
|
// (owner/repo, a path on the git seat, a URL), and its number. False when the merge names none.
|
||||||
|
func pullOf(m inventory.PlanMerge) (link.PullRef, bool) {
|
||||||
|
parts := strings.Split(strings.Trim(strings.TrimSuffix(m.Repository, ".git"), "/"), "/")
|
||||||
|
if len(parts) < 2 || m.Number <= 0 || parts[len(parts)-2] == "" || parts[len(parts)-1] == "" {
|
||||||
|
return link.PullRef{}, false
|
||||||
|
}
|
||||||
|
return link.PullRef{Owner: parts[len(parts)-2], Repo: parts[len(parts)-1], Number: m.Number}, true
|
||||||
|
}
|
||||||
|
|
||||||
// askModule asks the build machine for one module of a plan and marks it asked, with the id it was
|
// askModule asks the build machine for one module of a plan and marks it asked, with the id it was
|
||||||
// asked under (novox/hq ADR 0219) — or failed, with the plan, when it could not be asked.
|
// asked under (novox/hq ADR 0219) — or failed, with the plan, when it could not be asked.
|
||||||
func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[string]inventory.Entry) {
|
func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[string]inventory.Entry) {
|
||||||
@@ -340,7 +435,9 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
|
|||||||
if p.Delivery != nil && p.Delivery.Alone && carried != "" {
|
if p.Delivery != nil && p.Delivery.Alone && carried != "" {
|
||||||
ref = carried
|
ref = carried
|
||||||
}
|
}
|
||||||
id, err := askABuild(ctx, source, e.Source.Path, ref)
|
// **The walk takes the check's build of the same key** (novox/hq ADR 0350 §2): its ask says so, with the pull
|
||||||
|
// requests it answers, whose checks name the part of a differing key.
|
||||||
|
id, err := askABuild(withReuse(ctx, reuseWithVerdicts(ctx, reuseAskOf(p))), source, e.Source.Path, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
state.State = "failed"
|
state.State = "failed"
|
||||||
state.Why = err.Error()
|
state.Why = err.Error()
|
||||||
@@ -359,7 +456,7 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
|
|||||||
state.State = "asked"
|
state.State = "asked"
|
||||||
state.AskedAt = &now
|
state.AskedAt = &now
|
||||||
state.Build = id
|
state.Build = id
|
||||||
state.Why, state.Commit, state.BuiltAt = "", "", nil
|
state.Why, state.Commit, state.BuiltAt, state.Made = "", "", nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and
|
// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and
|
||||||
@@ -371,6 +468,12 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
|
|||||||
// would send machines, and the next tier, what the later merge replaced. asked is zero when the
|
// would send machines, and the next tier, what the later merge replaced. asked is zero when the
|
||||||
// build's request time is not known, and such an outcome is taken as before.
|
// build's request time is not known, and such an outcome is taken as before.
|
||||||
func planBuilt(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id string) {
|
func planBuilt(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id string) {
|
||||||
|
planBuiltSaying(ctx, open, module, commit, failed, asked, id, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// planBuiltSaying is planBuilt keeping what the build did — "reused from <check build>" or "built: <why>" (novox/hq
|
||||||
|
// ADR 0350 §4) — on the module of the walk it answers.
|
||||||
|
func planBuiltSaying(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id, made string) {
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
// One controller works the plans at a time (novox/hq issue 213); an outcome waits its turn rather
|
// One controller works the plans at a time (novox/hq issue 213); an outcome waits its turn rather
|
||||||
// than write over what the holder is about to save. Not taken, it is still in the build records,
|
// than write over what the holder is about to save. Not taken, it is still in the build records,
|
||||||
@@ -436,6 +539,7 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string,
|
|||||||
state.State = "built"
|
state.State = "built"
|
||||||
state.BuiltAt = &now
|
state.BuiltAt = &now
|
||||||
state.Commit = commit
|
state.Commit = commit
|
||||||
|
state.Made = made
|
||||||
}
|
}
|
||||||
if err := inv.SavePlan(ctx, p); err != nil {
|
if err := inv.SavePlan(ctx, p); err != nil {
|
||||||
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
||||||
@@ -505,7 +609,7 @@ func advancePlans(ctx context.Context, open *stores) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
// What waits for a gate, released one machine at a time (ADR 0236).
|
// What waits for a gate, released one machine first, then the rest at once (ADR 0236, ADR 0353).
|
||||||
if _, err := releaseBacklog(ctx, open, ""); err != nil {
|
if _, err := releaseBacklog(ctx, open, ""); err != nil {
|
||||||
fmt.Printf("plans: what waits for a gate could not be looked at: %v\n", err)
|
fmt.Printf("plans: what waits for a gate could not be looked at: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -715,7 +819,7 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
|||||||
} else if same {
|
} else if same {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
state.SentAt = &now
|
state.SentAt = &now
|
||||||
state.Why = "no move: made from the source every machine running it runs"
|
state.Why = noMoveWhy
|
||||||
fmt.Printf("%s: %s built from the source every machine running it runs: no move, nothing sent\n", p.ID, m)
|
fmt.Printf("%s: %s built from the source every machine running it runs: no move, nothing sent\n", p.ID, m)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -905,6 +1009,25 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
|||||||
if state.BuiltAt != nil {
|
if state.BuiltAt != nil {
|
||||||
since = *state.BuiltAt
|
since = *state.BuiltAt
|
||||||
}
|
}
|
||||||
|
// **A build that is no move is waited on by the send that last carried it** (novox/hq issue 648).
|
||||||
|
// Nothing was sent for it, so no report follows its marking or its build: a machine that does not
|
||||||
|
// happen to report again held the walk for ever. Each machine runs it once it answered its last
|
||||||
|
// send — the one whose builds made it no move — whenever that was.
|
||||||
|
if noMove(state) {
|
||||||
|
byNode := map[string]inventory.Reported{}
|
||||||
|
for _, r := range reports {
|
||||||
|
byNode[r.Node] = r
|
||||||
|
}
|
||||||
|
if ok, on := appliedEach(m, func(node string) time.Time {
|
||||||
|
if s := byNode[node].Sent; s != nil {
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}, running, reports); !ok {
|
||||||
|
waiting = append(waiting, fmt.Sprintf("%s on %s", m, strings.Join(on, ", ")))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
// **Each machine from its own send** (novox/hq issue 256). With one machine first (ADR 0218)
|
// **Each machine from its own send** (novox/hq issue 256). With one machine first (ADR 0218)
|
||||||
// a module is sent twice — the first machine, then the rest — and SentAt is the second.
|
// a module is sent twice — the first machine, then the rest — and SentAt is the second.
|
||||||
// Asked of every machine, the first machine's report, made between the two sends, read as
|
// Asked of every machine, the first machine's report, made between the two sends, read as
|
||||||
@@ -966,10 +1089,36 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string
|
|||||||
runningOf map[string][]string) error {
|
runningOf map[string][]string) error {
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
before, beforeKnown, _ := inv.SentBuilds(ctx, node)
|
before, beforeKnown, _ := inv.SentBuilds(ctx, node)
|
||||||
|
// **A machine already sent what this send is for is not sent again** (novox/hq ADR 0353 §6): sent by this plan
|
||||||
|
// before a controller was replaced and before it was kept, or by another walk. Judged from that send, and from
|
||||||
|
// the readings of a gate already judging it there.
|
||||||
|
// Only a send the machine reported it applied is one it has; one it never reported on is sent, as before
|
||||||
|
// (issue 280: what it ran before is then that build, and a failure leaves it as it was).
|
||||||
|
if beforeKnown && slices.IndexFunc(modules, func(m string) bool { return !sameCommit(before[m], p.Modules[m].Commit) }) < 0 &&
|
||||||
|
appliedLastSend(ctx, inv, node) {
|
||||||
|
if others, err := gatedMoves(ctx, open, node, nil); err == nil && len(others) == 0 {
|
||||||
|
adoptSend(ctx, open, p, node, modules, before, runningOf)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// **What the machine ran before is kept before the send is made** (novox/hq ADR 0353 §6): a controller replaced
|
||||||
|
// between the send and keeping it reads it here, and a failure still puts the machine back.
|
||||||
|
kept := false
|
||||||
|
for _, m := range modules {
|
||||||
|
s := p.Modules[m]
|
||||||
|
if beforeKnown && (s.Previous == "" || !sameCommit(before[m], s.Commit)) && s.Previous != before[m] {
|
||||||
|
s.Previous, kept = before[m], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kept {
|
||||||
|
if err := inv.SavePlan(ctx, p); err != nil {
|
||||||
|
return fmt.Errorf("what %s ran before is not kept, so nothing is sent: %w", node, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
owns := make([]inventory.CarriedMove, 0, len(modules))
|
owns := make([]inventory.CarriedMove, 0, len(modules))
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
s := p.Modules[m]
|
s := p.Modules[m]
|
||||||
owns = append(owns, inventory.CarriedMove{Module: m, Node: node, From: before[m], To: s.Commit, Build: s.Build})
|
owns = append(owns, inventory.CarriedMove{Module: m, Node: node, From: s.Previous, To: s.Commit, Build: s.Build})
|
||||||
}
|
}
|
||||||
// A gated send (ADR 0236): everything waiting on the machine goes with the tier, and the gate judges
|
// A gated send (ADR 0236): everything waiting on the machine goes with the tier, and the gate judges
|
||||||
// all of it there.
|
// all of it there.
|
||||||
@@ -984,10 +1133,7 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string
|
|||||||
sentWhat := sentNow(ctx, inv, sent)
|
sentWhat := sentNow(ctx, inv, sent)
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
s := p.Modules[m]
|
s := p.Modules[m]
|
||||||
// What the first machine ran before: what a failed gate puts back (ADR 0236).
|
// What the first machine ran before (ADR 0236), kept above before the send.
|
||||||
if beforeKnown {
|
|
||||||
s.Previous = before[m]
|
|
||||||
}
|
|
||||||
s.First, s.FirstAt = sent, &now
|
s.First, s.FirstAt = sent, &now
|
||||||
s.Gate = &inventory.PlanGate{Component: coreComponent(m), Machines: firstRunning(sent, runningOf[m]),
|
s.Gate = &inventory.PlanGate{Component: coreComponent(m), Machines: firstRunning(sent, runningOf[m]),
|
||||||
From: s.Previous, To: s.Commit, Since: &now, Sent: sentWhat}
|
From: s.Previous, To: s.Commit, Since: &now, Sent: sentWhat}
|
||||||
@@ -1016,6 +1162,113 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adoptSend records a first send the machine already had (novox/hq ADR 0353 §6, §7): nothing is sent; the gate counts
|
||||||
|
// from the machine's last send, and takes over the readings of an open gate of another walk judging the same build
|
||||||
|
// there — whose carried move also says what the machine ran before, for a rollback.
|
||||||
|
func adoptSend(ctx context.Context, open *stores, p *inventory.Plan, node string, modules []string,
|
||||||
|
before map[string]string, runningOf map[string][]string) {
|
||||||
|
inv := open.inventory
|
||||||
|
since := time.Now().UTC()
|
||||||
|
last, found, err := inv.SentTo(ctx, node)
|
||||||
|
if err == nil && found && !last.At.IsZero() {
|
||||||
|
since = last.At
|
||||||
|
}
|
||||||
|
lead := modules[0]
|
||||||
|
other, id, from := judgingThere(ctx, inv, p.ID, lead, node, p.Modules[lead].Commit, p.Modules[lead].Build)
|
||||||
|
for _, m := range modules {
|
||||||
|
s := p.Modules[m]
|
||||||
|
// What it ran before: kept by this walk before its send was made (§6); else what the other walk's send
|
||||||
|
// moved it from; else the build itself, so a failure leaves it as it was (issue 280).
|
||||||
|
switch {
|
||||||
|
case s.Previous != "":
|
||||||
|
case m == lead && from != "":
|
||||||
|
s.Previous = from
|
||||||
|
default:
|
||||||
|
s.Previous = before[m]
|
||||||
|
}
|
||||||
|
at := since
|
||||||
|
s.First, s.FirstAt = []string{node}, &at
|
||||||
|
s.Gate = &inventory.PlanGate{Component: coreComponent(m), Machines: firstRunning([]string{node}, runningOf[m]),
|
||||||
|
From: s.Previous, To: s.Commit, Since: &at}
|
||||||
|
if found {
|
||||||
|
s.Gate.Sent = map[string]inventory.SentDeclaration{node: last}
|
||||||
|
}
|
||||||
|
s.GatedBy = ""
|
||||||
|
if m == lead {
|
||||||
|
s.Gate.TakeReadings(other, modules, id)
|
||||||
|
} else {
|
||||||
|
s.GatedBy = lead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.State = inventory.PlanRolling
|
||||||
|
p.Note = fmt.Sprintf("tier %d built; %s already runs %s's build: nothing sent again, judged from that send",
|
||||||
|
p.Tier, node, strings.Join(modules, ", "))
|
||||||
|
if id != "" {
|
||||||
|
p.Note += ", going on from the readings of " + id
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appliedLastSend says a machine reported it applied the declaration it was sent last.
|
||||||
|
func appliedLastSend(ctx context.Context, inv *inventory.Inventory, node string) bool {
|
||||||
|
reports, err := inv.LastReports(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range reports {
|
||||||
|
if r.Node == node {
|
||||||
|
return r.Current && r.At != nil && r.Outcome == inventory.OutcomeApplied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgingThere is an open gate of another walk judging a module's build on a machine (novox/hq ADR 0353 §7), the
|
||||||
|
// walk's id, and what the machine ran before; nil when none does.
|
||||||
|
func judgingThere(ctx context.Context, inv *inventory.Inventory, self, module, node, commit, build string) (*inventory.PlanGate, string, string) {
|
||||||
|
plans, err := inv.OpenPlans(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", ""
|
||||||
|
}
|
||||||
|
// The same build, by its id where both name one, else by its commit.
|
||||||
|
same := func(b, c string) bool {
|
||||||
|
if b != "" && build != "" {
|
||||||
|
return b == build
|
||||||
|
}
|
||||||
|
return sameCommit(c, commit)
|
||||||
|
}
|
||||||
|
usable := func(g *inventory.PlanGate) bool {
|
||||||
|
return g != nil && !g.Pending && g.Since != nil && (g.Verdict == "" || g.Verdict == inventory.GatePassed) &&
|
||||||
|
slices.Contains(g.Machines, node)
|
||||||
|
}
|
||||||
|
for _, q := range plans {
|
||||||
|
if q.ID == self {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r := q.Release; r != nil {
|
||||||
|
gates := []*inventory.PlanGate{r.Gate}
|
||||||
|
for _, g := range r.Gates {
|
||||||
|
gates = append(gates, g)
|
||||||
|
}
|
||||||
|
for _, g := range gates {
|
||||||
|
if !usable(g) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range g.Carried {
|
||||||
|
if c.Module == module && c.Node == node && same(c.Build, c.To) {
|
||||||
|
return g, q.ID, c.From
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := q.Modules[module]; s != nil && usable(s.Gate) && same(s.Build, s.Commit) {
|
||||||
|
return s.Gate, q.ID, s.Previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
// passedWith keeps, on a module sent with others, the verdict of the gate that judged the send: the gate
|
// passedWith keeps, on a module sent with others, the verdict of the gate that judged the send: the gate
|
||||||
// on the first of them passed, and its pass was kept for every build it carried (passCarried).
|
// on the first of them passed, and its pass was kept for every build it carried (passCarried).
|
||||||
func passedWith(module string, s *inventory.PlanModule, lead string, g *inventory.PlanGate) {
|
func passedWith(module string, s *inventory.PlanModule, lead string, g *inventory.PlanGate) {
|
||||||
@@ -1504,9 +1757,19 @@ func plansCommand(ctx context.Context, args []string) error {
|
|||||||
// A release plan's walk (ADR 0236): machines done, the one judged, those to come.
|
// A release plan's walk (ADR 0236): machines done, the one judged, those to come.
|
||||||
fmt.Printf(" machines in order: %s; done: %s; skipped: %s\n", strings.Join(r.Order, ", "),
|
fmt.Printf(" machines in order: %s; done: %s; skipped: %s\n", strings.Join(r.Order, ", "),
|
||||||
orNone(strings.Join(r.Done, ", ")), orNone(strings.Join(r.Skipped, ", ")))
|
orNone(strings.Join(r.Done, ", ")), orNone(strings.Join(r.Skipped, ", ")))
|
||||||
if r.Gate != nil {
|
if len(r.Failed)+len(r.Silent) > 0 {
|
||||||
fmt.Printf(" %s\n", gateLine(r.Gate))
|
fmt.Printf(" failed: %s; silent: %s\n", orNone(strings.Join(r.Failed, ", ")), orNone(strings.Join(r.Silent, ", ")))
|
||||||
for _, c := range r.Gate.Carried {
|
}
|
||||||
|
gates := []*inventory.PlanGate{r.Gate}
|
||||||
|
for _, n := range r.Order {
|
||||||
|
gates = append(gates, r.Gates[n])
|
||||||
|
}
|
||||||
|
for _, g := range gates {
|
||||||
|
if g == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s\n", gateLine(g))
|
||||||
|
for _, c := range g.Carried {
|
||||||
fmt.Printf(" %-22s %s → %s\n", c.Module, short(c.From), short(c.To))
|
fmt.Printf(" %-22s %s → %s\n", c.Module, short(c.From), short(c.To))
|
||||||
if c.Recreates != "" {
|
if c.Recreates != "" {
|
||||||
fmt.Printf(" %-22s %s\n", "", c.Recreates)
|
fmt.Printf(" %-22s %s\n", "", c.Recreates)
|
||||||
|
|||||||
@@ -133,11 +133,15 @@ func aBacklog(t *testing.T) *backlogMesh {
|
|||||||
}
|
}
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
|
wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
|
||||||
gateSettle, gateEvery = 0, time.Hour
|
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
|
||||||
|
wasWindow := gateWitnessWindow
|
||||||
|
gateWitnessWindow = 0
|
||||||
|
t.Cleanup(func() { gateWitnessWindow = wasWindow })
|
||||||
|
gateWithin, gateEvery = time.Hour, time.Hour
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
|
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
|
||||||
gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound
|
gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound
|
||||||
})
|
})
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReplay164 replays novox/hq issue 164 (found provisioning the media machine's modules, re-checked
|
||||||
|
// 2026-10-08): a Servarr app's API key, an indexer's key and a downloader's password are values the provider's
|
||||||
|
// software holds and only a person can give, and for a consumer of such a provision the send made a random pair
|
||||||
|
// credential, sealed it to both ends and placed it; the provider refused it, and a consumer retrying it could be
|
||||||
|
// banned by the provider. The outcome asserted (ADR 0337): for a provision whose offer says its credential is
|
||||||
|
// issued outside the mesh, the send makes no value and places none, the consumer's machine still composes, and a
|
||||||
|
// value a person gave for the pair is placed. Written with only what the controller had before its fix —
|
||||||
|
// register, assign, planFor, declarationFor, AcceptSecretForPair and the inventory's secret reads — so it is laid
|
||||||
|
// over the commit before. Needs the store (MESH_TEST_POSTGRES), and is named in testdata/store-tests.
|
||||||
|
func TestReplay164(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
const secretFile = "/var/lib/mesh/requests/tv-api.secret"
|
||||||
|
register(t, open, catalogue.Manifest{Module: "tv", Version: "1",
|
||||||
|
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh,
|
||||||
|
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}},
|
||||||
|
Serves: map[string]map[string]any{"tv-api": {"port": 8989}}})
|
||||||
|
register(t, open, catalogue.Manifest{Module: "requests", Version: "1", Requires: []string{"tv-api"},
|
||||||
|
Secrets: map[string]string{"tv-api": secretFile}})
|
||||||
|
for _, a := range [][2]string{{"anchor", "tv"}, {"laptop", "requests"}} {
|
||||||
|
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sent := func() []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
plan, settings, err := planFor(ctx, open, "laptop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the consumer's machine was refused for a credential only a person gives: %v", err)
|
||||||
|
}
|
||||||
|
declared, err := declarationFor(ctx, open, "laptop", plan, settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the send to laptop did not compose: %v", err)
|
||||||
|
}
|
||||||
|
return declared.Resources
|
||||||
|
}
|
||||||
|
placed := func(resources []map[string]any) map[string]any {
|
||||||
|
for _, r := range resources {
|
||||||
|
if r["path"] == secretFile && r["sealed"] != nil {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := placed(sent()); got != nil {
|
||||||
|
t.Errorf("the send placed a credential for tv-api (%v), which only a person can give and nobody gave", got["id"])
|
||||||
|
}
|
||||||
|
held, err := open.inventory.SecretsOf(ctx, "laptop", "requests")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range held {
|
||||||
|
if s.Name == "tv-api" {
|
||||||
|
t.Fatalf("the mesh holds a value for tv-api that nobody gave (origin %q)", s.Origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given by a person for the pair, it is placed, as given.
|
||||||
|
if err := open.inventory.AcceptSecretForPair(ctx, "tv-api", "laptop", "requests", "anchor", "", "the app's key"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if placed(sent()) == nil {
|
||||||
|
t.Fatal("the value a person gave for tv-api was not placed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,10 +112,14 @@ func TestReplay318(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wasMoves, wasHeard, wasSend, wasGather := machineMoves, releaseHeard, sendRollout, gatherGateFacts
|
wasMoves, wasHeard, wasSend, wasGather := machineMoves, releaseHeard, sendRollout, gatherGateFacts
|
||||||
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
|
wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
|
||||||
|
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
|
||||||
|
wasWindow := gateWitnessWindow
|
||||||
|
gateWitnessWindow = 0
|
||||||
|
t.Cleanup(func() { gateWitnessWindow = wasWindow })
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
|
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
|
||||||
gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound
|
gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound
|
||||||
})
|
})
|
||||||
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
|
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
|
||||||
modules, err := open.inventory.Assigned(ctx, node)
|
modules, err := open.inventory.Assigned(ctx, node)
|
||||||
@@ -184,7 +188,7 @@ func TestReplay318(t *testing.T) {
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
// The bound is reached at the first judging that is not a pass: what happened at 11:21 happens at once.
|
// The bound is reached at the first judging that is not a pass: what happened at 11:21 happens at once.
|
||||||
gateSettle, gateEvery, gateBound = 0, 0, 0
|
gateWithin, gateEvery, gateBound = time.Hour, 0, 0
|
||||||
|
|
||||||
for i := 0; i < 12; i++ {
|
for i := 0; i < 12; i++ {
|
||||||
advancePlans(ctx, open)
|
advancePlans(ctx, open)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **A new SDK plans the toolchain, and then every bundle compiled in it** (novox/hq issue 614), reasoned
|
||||||
|
// as `mesh-controller.delivery-plan` reasons it: the modules registered as a build registers them —
|
||||||
|
// resolved, so carrying no `build` — their builds recorded with what they stood on and what they made,
|
||||||
|
// the graph read by theGraph and the change scoped by pullScope. On 2026-10-13 mesh-sdk 0.1.14 planned
|
||||||
|
// "builds mesh-sdk, sent nowhere": mesh-tools' `build.on` naming the SDK's package and the providers'
|
||||||
|
// TypeScript bundles were edges only while their manifests were unbuilt.
|
||||||
|
//
|
||||||
|
// **The replay of issue 614** (mesh-lab R614): written only with what the controller had before its fix — a
|
||||||
|
// store, modules registered and builds recorded, theGraph and pullScope — so it is laid over the commit before
|
||||||
|
// and fails there with the plan of that day, [[mesh-sdk]]. Needs a store: MESH_TEST_POSTGRES.
|
||||||
|
func TestReplay614ANewSDKPlansTheToolchainAndThenItsBundles(t *testing.T) {
|
||||||
|
inv := inventory.ForTest(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
asked := time.Now().Add(-time.Hour)
|
||||||
|
digest := func(c string) string { return "@sha256:" + strings.Repeat(c, 64) }
|
||||||
|
register := func(m catalogue.Manifest, repository, path string, against []string, made []inventory.Artifact) {
|
||||||
|
t.Helper()
|
||||||
|
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
|
||||||
|
Ref: "main", BuiltFrom: "old", Asked: asked}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
|
||||||
|
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Made: made, Asked: asked}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bundle := func(module, language string) catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: module, Version: "1", Bundles: []catalogue.Bundle{{
|
||||||
|
Name: "code", Language: language, Source: catalogue.ArtifactStoreScheme + module + "/code" + digest("2"),
|
||||||
|
Entrypoints: []string{"tools/index.js"}}}}
|
||||||
|
}
|
||||||
|
register(catalogue.Manifest{Module: "build-agent", Version: "1",
|
||||||
|
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}},
|
||||||
|
"novox/mesh-catalog", "modules/build-agent", nil, nil)
|
||||||
|
register(catalogue.Manifest{Module: "mesh-sdk", Version: "1"}, "novox/mesh-sdk", "", nil,
|
||||||
|
[]inventory.Artifact{{Name: "lib", Kind: "package", Reference: "@novox/mesh-sdk@0.1.13"}})
|
||||||
|
register(catalogue.Manifest{Module: "mesh-tools", Version: "1"}, "novox/mesh-tools", "",
|
||||||
|
[]string{"@novox/mesh-sdk@0.1.13", catalogue.ArtifactStoreScheme + "upstream/docker.io/library/node" + digest("0")},
|
||||||
|
[]inventory.Artifact{{Name: "build", Kind: "image", Reference: catalogue.ArtifactStoreScheme + "mesh-tools/build" + digest("1")}})
|
||||||
|
register(catalogue.Manifest{Module: "mesh-tools-go", Version: "1"}, "novox/mesh-tools", "go", nil, nil)
|
||||||
|
register(bundle("minio", "typescript"), "novox/mesh-catalog", "modules/minio", nil, nil)
|
||||||
|
register(bundle("umami", "typescript"), "novox/mesh-catalog", "modules/umami", nil, nil)
|
||||||
|
register(bundle("dnsmasq", "go"), "novox/mesh-catalog", "modules/dnsmasq", nil, nil)
|
||||||
|
|
||||||
|
entries, read, edges, err := theGraph(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := orderMember{ID: "novox/mesh-sdk@new", Repository: "novox/mesh-sdk", Head: "new",
|
||||||
|
Paths: []string{"src/provisioner/index.ts", "package.json"}}
|
||||||
|
s := pullScope(m.pull(), entries, read, edges)
|
||||||
|
want := [][]string{{"mesh-sdk"}, {"mesh-tools"}, {"minio", "umami"}}
|
||||||
|
if !reflect.DeepEqual(s.Reach.Plan.Tiers, want) {
|
||||||
|
t.Fatalf("a new SDK plans %v, want %v (edges %v)", s.Reach.Plan.Tiers, want, edges)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(s.Dependents, []string{"mesh-tools", "minio", "umami"}) {
|
||||||
|
t.Errorf("dependents %v", s.Dependents)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,9 +248,13 @@ func TestReplayCrashLoopFailsItsGateOnTheFirstMachine(t *testing.T) {
|
|||||||
return names, nil
|
return names, nil
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { sendRollout = wasSend })
|
t.Cleanup(func() { sendRollout = wasSend })
|
||||||
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
|
wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
|
||||||
gateSettle, gateEvery = 0, 0
|
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
|
||||||
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound })
|
wasWindow := gateWitnessWindow
|
||||||
|
gateWitnessWindow = 0
|
||||||
|
t.Cleanup(func() { gateWitnessWindow = wasWindow })
|
||||||
|
gateWithin, gateEvery = time.Hour, 0
|
||||||
|
t.Cleanup(func() { gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound })
|
||||||
|
|
||||||
built := time.Now().UTC()
|
built := time.Now().UTC()
|
||||||
plan := inventory.Plan{ID: "plan-crashloop", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2",
|
plan := inventory.Plan{ID: "plan-crashloop", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2",
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0353: after its first machine passes, a build goes to every other machine at once, each judged by
|
||||||
|
// its own gate and a failing one put back by itself; and a gate's readings outlive the controller that took them —
|
||||||
|
// a machine already sent what a send is for is not sent again, and its judging goes on from what was read.
|
||||||
|
//
|
||||||
|
// Seen 2026-10-14: release-1791930194184991192 sent ace, g14, shanks and the control node one after another in
|
||||||
|
// eleven minutes; the new controller its last send started sent the control node again the controller build it
|
||||||
|
// had just applied, and judged it from its first reading.
|
||||||
|
|
||||||
|
// aBacklogOnThree is the backlog fixture with a third machine, desk, running app at c1 like the others.
|
||||||
|
func aBacklogOnThree(t *testing.T) *backlogMesh {
|
||||||
|
t.Helper()
|
||||||
|
b := aBacklog(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := b.open.inventory
|
||||||
|
if _, err := inv.AddNode(ctx, "desk"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := inv.Assign(ctx, "desk", "app"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RecordSent(ctx, nodeID(t, b.open, "desk"), "d-desk", map[string]string{"app": "c1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wasHeard := releaseHeard
|
||||||
|
releaseHeard = func(context.Context, *stores) (map[string]bool, error) {
|
||||||
|
return map[string]bool{"anchor": true, "laptop": true, "desk": true}, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { releaseHeard = wasHeard })
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// events records every send and every judging, in order.
|
||||||
|
func events(t *testing.T) *[]string {
|
||||||
|
t.Helper()
|
||||||
|
var said []string
|
||||||
|
wasSend, wasGather := sendRollout, gatherGateFacts
|
||||||
|
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||||
|
said = append(said, "send "+strings.Join(names, ","))
|
||||||
|
return wasSend(ctx, open, names)
|
||||||
|
}
|
||||||
|
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
|
||||||
|
said = append(said, "judge")
|
||||||
|
return wasGather(ctx, open, component)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { sendRollout, gatherGateFacts = wasSend, wasGather })
|
||||||
|
return &said
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the first machine passed, the rest are both sent before either is judged (ADR 0353 §1); before, the second
|
||||||
|
// was judged to its verdict before the third was sent.
|
||||||
|
func TestAReleaseSendsTheRestAtOnceAfterTheFirstPasses(t *testing.T) {
|
||||||
|
judgeLeavesAsTheCore(t)
|
||||||
|
b := aBacklogOnThree(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
said := events(t)
|
||||||
|
gateEvery = 0
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
}
|
||||||
|
p := b.release(t)
|
||||||
|
if p.State != inventory.PlanDone || len(p.Release.Done) != 3 {
|
||||||
|
t.Fatalf("the release is %s: %s %+v", p.State, p.Note, p.Release)
|
||||||
|
}
|
||||||
|
desk, laptop := slices.Index(*said, "send desk"), slices.Index(*said, "send laptop")
|
||||||
|
if desk < 0 || laptop < 0 || slices.Index(*said, "send anchor") > min(desk, laptop) {
|
||||||
|
t.Fatalf("sends and judgings: %v", *said)
|
||||||
|
}
|
||||||
|
if slices.Contains((*said)[min(desk, laptop):max(desk, laptop)], "judge") {
|
||||||
|
t.Fatalf("the rest were sent one at a time, each judged before the next: %v", *said)
|
||||||
|
}
|
||||||
|
if v, found, err := b.open.inventory.GateOf(ctx, "build-late-c2"); err != nil || !found || v.Verdict != inventory.GatePassed {
|
||||||
|
t.Fatalf("late's pass on laptop was not kept: %+v %v %v", v, found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine of the rest whose gate fails is put back at once, and the other machine of the step goes on to its own
|
||||||
|
// verdict (ADR 0353 §3); before, the walk stopped at the failure and the machine after it was never sent.
|
||||||
|
func TestARestMachineThatFailsIsPutBackAloneAndTheOthersAreJudgedOn(t *testing.T) {
|
||||||
|
judgeLeavesAsTheCore(t)
|
||||||
|
b := aBacklogOnThree(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := b.open.inventory
|
||||||
|
gateEvery = 0
|
||||||
|
b.broken["desk"] = true // desk fails what it is sent in the rest's step
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
for i := 0; i < 12 && b.release(t).Open(); i++ {
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
}
|
||||||
|
p := b.release(t)
|
||||||
|
if p.State != inventory.PlanFailed || !slices.Equal(p.Release.Failed, []string{"desk"}) {
|
||||||
|
t.Fatalf("the release is %s: %s %+v", p.State, p.Note, p.Release)
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(b.sent, func(s []string) bool { return slices.Equal(s, []string{"laptop"}) }) {
|
||||||
|
t.Fatalf("laptop was never sent: %v", b.sent)
|
||||||
|
}
|
||||||
|
if !slices.Contains(p.Release.Done, "laptop") {
|
||||||
|
t.Fatalf("laptop has no verdict of its own: %+v", p.Release)
|
||||||
|
}
|
||||||
|
// late waited on laptop alone and was healthy there: its pass is kept, whatever desk did.
|
||||||
|
if v, found, err := inv.GateOf(ctx, "build-late-c2"); err != nil || !found || v.Verdict != inventory.GatePassed {
|
||||||
|
t.Fatalf("late's pass on laptop was not kept: %+v %v %v", v, found, err)
|
||||||
|
}
|
||||||
|
// app failed on desk: marked, and put back wherever it was sent (ADR 0236).
|
||||||
|
if failed, _ := inv.GateFailed(ctx, "build-app-c2"); !failed {
|
||||||
|
t.Fatal("app's build is not marked failed")
|
||||||
|
}
|
||||||
|
if sent, _, _ := inv.SentBuilds(ctx, "desk"); sent["app"] != "c1" {
|
||||||
|
t.Fatalf("desk was not put back: %v", sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A walk whose first machine already runs its build under another walk's gate sends nothing and goes on from that
|
||||||
|
// gate's readings (ADR 0353 §7); before, it sent the machine again and judged from its first reading.
|
||||||
|
func TestAWalkTakesOverTheReadingsOfAGateJudgingTheSameBuildThere(t *testing.T) {
|
||||||
|
judgeLeavesAsTheCore(t)
|
||||||
|
b := aBacklog(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := b.open.inventory
|
||||||
|
advancePlans(ctx, b.open) // the release sends anchor app c2, and reads it once
|
||||||
|
release := b.release(t)
|
||||||
|
judging := release.Release.Gate
|
||||||
|
if judging == nil || judging.Pending || judging.Passes == 0 {
|
||||||
|
t.Fatalf("the release is not judging anchor: %+v", judging)
|
||||||
|
}
|
||||||
|
built := time.Now().UTC()
|
||||||
|
plan := inventory.Plan{ID: "plan-app", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2",
|
||||||
|
Created: built, State: inventory.PlanBuilding, Tiers: [][]string{{"app"}},
|
||||||
|
Modules: map[string]*inventory.PlanModule{"app": {State: "built", BuiltAt: &built, Commit: "c2",
|
||||||
|
Build: "build-app-c2"}}}
|
||||||
|
if err := inv.SavePlan(ctx, &plan); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
if len(b.sent) != 1 {
|
||||||
|
t.Fatalf("anchor was sent again what it runs: %v", b.sent)
|
||||||
|
}
|
||||||
|
p, err := inv.PlanByID(ctx, "plan-app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g := p.Modules["app"].Gate
|
||||||
|
if g == nil || g.Since == nil || !g.Since.Equal(*judging.Since) || g.Passes < judging.Passes || g.TookOver != release.ID {
|
||||||
|
t.Fatalf("the walk's gate starts from zero: %+v (the release's: %+v)", g, judging)
|
||||||
|
}
|
||||||
|
if p.Modules["app"].Previous != "c1" {
|
||||||
|
t.Fatalf("what anchor ran before is %q, not c1", p.Modules["app"].Previous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A walk's first send that was made and not kept — the controller replaced between the two — is not made again
|
||||||
|
// by the controller that takes over: the gate counts from that send (ADR 0353 §5, §6).
|
||||||
|
func TestAFirstSendMadeAndNotKeptIsNotSentAgain(t *testing.T) {
|
||||||
|
judgeLeavesAsTheCore(t)
|
||||||
|
g := aGateMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
// The old controller sends anchor app c2 and is replaced before it keeps the send.
|
||||||
|
made := sendRollout
|
||||||
|
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||||
|
if _, err := made(ctx, open, names); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
advancePlans(ctx, g.open)
|
||||||
|
sendRollout = made
|
||||||
|
if len(g.sent) != 1 || g.plan(t).Modules["app"].FirstAt != nil {
|
||||||
|
t.Fatalf("the fixture's send was not made, or was kept: %v", g.sent)
|
||||||
|
}
|
||||||
|
last, _, err := g.open.inventory.SentTo(ctx, "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
advancePlans(ctx, g.open)
|
||||||
|
if len(g.sent) != 1 {
|
||||||
|
t.Fatalf("the send was made again: %v", g.sent)
|
||||||
|
}
|
||||||
|
s := g.plan(t).Modules["app"]
|
||||||
|
if s.FirstAt == nil || s.Gate == nil || s.Gate.Since == nil || !s.Gate.Since.Equal(last.At) {
|
||||||
|
t.Fatalf("the gate does not count from the send made: %+v %+v", s, s.Gate)
|
||||||
|
}
|
||||||
|
// What anchor ran before was kept before the send, so a failure still puts it back (§5).
|
||||||
|
if s.Previous != "c1" || s.Gate.From != "c1" {
|
||||||
|
t.Fatalf("what anchor ran before is %q (gate from %q), not c1", s.Previous, s.Gate.From)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A release's send kept before it was made, and made, is judged by the controller that takes over without being
|
||||||
|
// made again (ADR 0353 §8).
|
||||||
|
func TestAReleaseSendKeptAndMadeIsNotMadeAgain(t *testing.T) {
|
||||||
|
judgeLeavesAsTheCore(t)
|
||||||
|
b := aBacklog(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := b.open.inventory
|
||||||
|
p, err := releaseBacklog(ctx, b.open, "")
|
||||||
|
if err != nil || p == nil {
|
||||||
|
t.Fatalf("no release opened: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := advanceRelease(ctx, b.open, p); err != nil || p.Release.Gate == nil || !p.Release.Gate.Pending {
|
||||||
|
t.Fatalf("the send was not kept before it was made: %+v %v", p.Release, err)
|
||||||
|
}
|
||||||
|
if err := inv.SavePlan(ctx, p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The send is made, and the controller is replaced before it keeps it.
|
||||||
|
if _, err := sendRollout(ctx, b.open, []string{"anchor"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
if len(b.sent) != 1 {
|
||||||
|
t.Fatalf("anchor was sent again: %v", b.sent)
|
||||||
|
}
|
||||||
|
g := b.release(t).Release.Gate
|
||||||
|
if g == nil || g.Pending || g.Since == nil || len(g.Carried) == 0 {
|
||||||
|
t.Fatalf("anchor is not judged on the send made: %+v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A build no gate has passed goes to one machine of a step, its first; a machine that would be its second waits
|
||||||
|
// for the next step, and one whose every build passed goes now (ADR 0353 §2).
|
||||||
|
func TestABuildThatPassedNoGateGoesToOneMachineOfAStep(t *testing.T) {
|
||||||
|
waiting := map[string][]inventory.CarriedMove{
|
||||||
|
"desk": {{Module: "app", To: "c2"}, {Module: "new", To: "n1"}},
|
||||||
|
"laptop": {{Module: "app", To: "c2"}, {Module: "new", To: "n1"}},
|
||||||
|
"shanks": {{Module: "app", To: "c2"}},
|
||||||
|
"novox": {{Module: "app", To: "c2"}, {Module: "ctl", To: "k1"}},
|
||||||
|
}
|
||||||
|
passed := func(module, commit string) bool { return module == "app" && commit == "c2" }
|
||||||
|
never := func(string) bool { return false }
|
||||||
|
step := restStep([]string{"desk", "laptop", "shanks", "novox"}, waiting, passed, never)
|
||||||
|
if !slices.Equal(step, []string{"desk", "shanks", "novox"}) {
|
||||||
|
t.Fatalf("the step is %v: laptop would be new's second machine before it passed", step)
|
||||||
|
}
|
||||||
|
if step := restStep([]string{"laptop"}, waiting, func(string, string) bool { return true }, never); !slices.Equal(step, []string{"laptop"}) {
|
||||||
|
t.Fatalf("a machine whose every build passed waits: %v", step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The node-engine and the mesh's resolver go to the rest one machine at a time, even passed (ADR 0353 §9): a step
|
||||||
|
// carries either to one machine only, while what else passed goes everywhere at once.
|
||||||
|
func TestTheNodeEngineAndTheResolverGoToTheRestOneAtATime(t *testing.T) {
|
||||||
|
waiting := map[string][]inventory.CarriedMove{
|
||||||
|
"desk": {{Module: hostModule, To: "h2"}},
|
||||||
|
"laptop": {{Module: hostModule, To: "h2"}, {Module: "app", To: "c2"}},
|
||||||
|
"shanks": {{Module: "app", To: "c2"}},
|
||||||
|
"novox": {{Module: "dns", To: "d2"}},
|
||||||
|
"ace": {{Module: "dns", To: "d2"}},
|
||||||
|
}
|
||||||
|
resolver := catalogue.Manifest{Module: "dns", Claims: []catalogue.Claim{{Name: resolverSeat}}}
|
||||||
|
alone := func(m string) bool {
|
||||||
|
if m == "dns" {
|
||||||
|
return oneAtATime(m, resolver)
|
||||||
|
}
|
||||||
|
return oneAtATime(m, catalogue.Manifest{Module: m})
|
||||||
|
}
|
||||||
|
step := restStep([]string{"desk", "laptop", "shanks", "novox", "ace"}, waiting,
|
||||||
|
func(string, string) bool { return true }, alone)
|
||||||
|
if !slices.Equal(step, []string{"desk", "shanks", "novox"}) {
|
||||||
|
t.Fatalf("the step is %v: the node-engine and the resolver each reach one machine of it", step)
|
||||||
|
}
|
||||||
|
if oneAtATime("app", catalogue.Manifest{Module: "app"}) {
|
||||||
|
t.Fatal("a leaf module is held to one machine at a time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A first machine that fails still stops the walk (ADR 0236 §5, kept by ADR 0353 §3): the rest are never sent.
|
||||||
|
func TestAFirstMachineThatFailsStillStopsTheWalk(t *testing.T) {
|
||||||
|
b := aBacklogOnThree(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
gateEvery = 0
|
||||||
|
b.broken["anchor"] = true
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
for i := 0; i < 6 && b.release(t).Open(); i++ {
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
}
|
||||||
|
if p := b.release(t); p.State != inventory.PlanFailed || p.Release.Rest {
|
||||||
|
t.Fatalf("the release is %s: %s %+v", p.State, p.Note, p.Release)
|
||||||
|
}
|
||||||
|
for _, s := range b.sent {
|
||||||
|
if !slices.Equal(s, []string{"anchor"}) {
|
||||||
|
t.Fatalf("sent %v: the rest after a failed first machine", b.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The control node is sent last in a step of the rest (ADR 0353 §1): the machine holding the controller is
|
||||||
|
// ordered after every other.
|
||||||
|
func TestTheControlNodeIsSentLastInAStep(t *testing.T) {
|
||||||
|
judgeLeavesAsTheCore(t)
|
||||||
|
b := aBacklogOnThree(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, b.open, catalogue.Manifest{Module: catalogue.ControllerSeatName, Version: "1"})
|
||||||
|
if _, err := b.open.inventory.Assign(ctx, "anchor", catalogue.ControllerSeatName); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
said := events(t)
|
||||||
|
gateEvery = 0
|
||||||
|
for i := 0; i < 12 && (len(b.sent) == 0 || b.release(t).Open()); i++ {
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
}
|
||||||
|
if p := b.release(t); !slices.Equal(p.Release.Order, []string{"desk", "laptop", "anchor"}) {
|
||||||
|
t.Fatalf("the order is %v", p.Release.Order)
|
||||||
|
}
|
||||||
|
laptop, anchor := slices.Index(*said, "send laptop"), slices.Index(*said, "send anchor")
|
||||||
|
if laptop < 0 || anchor < laptop || slices.Contains((*said)[laptop:anchor], "judge") {
|
||||||
|
t.Fatalf("the control node is not sent last in the rest's step: %v", *said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine of the rest that says nothing on its send is left silent after fifteen minutes: not failed, not put
|
||||||
|
// back; a rest that went silent whole ends failed, said as a condition (ADR 0353 §4).
|
||||||
|
func TestARestMachineSilentPastItsBoundIsLeftAndSaid(t *testing.T) {
|
||||||
|
judgeLeavesAsTheCore(t)
|
||||||
|
b := aBacklog(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := b.open.inventory
|
||||||
|
reporting := sendRollout
|
||||||
|
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||||
|
if slices.Equal(names, []string{"laptop"}) { // laptop sleeps: sent, and says nothing
|
||||||
|
b.sent = append(b.sent, names)
|
||||||
|
current, _ := open.inventory.CurrentBuilds(ctx)
|
||||||
|
return names, open.inventory.RecordSent(ctx, nodeID(t, open, "laptop"), "d-laptop-asleep",
|
||||||
|
map[string]string{"app": current["app"].Commit, "late": current["late"].Commit, "same": current["same"].Commit})
|
||||||
|
}
|
||||||
|
return reporting(ctx, open, names)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { sendRollout = reporting })
|
||||||
|
gateEvery = 0
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
}
|
||||||
|
p := b.release(t)
|
||||||
|
g := p.Release.Gates["laptop"]
|
||||||
|
if !p.Open() || g == nil || g.Verdict != "" || g.Silent {
|
||||||
|
t.Fatalf("laptop is judged before fifteen minutes: %s %s %+v", p.State, p.Note, g)
|
||||||
|
}
|
||||||
|
since := time.Now().Add(-inventory.ApplySilentAfter - time.Minute)
|
||||||
|
g.Since = &since
|
||||||
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
advancePlans(ctx, b.open)
|
||||||
|
p = b.release(t)
|
||||||
|
if p.State != inventory.PlanFailed || !slices.Equal(p.Release.Silent, []string{"laptop"}) || len(p.Release.Failed) > 0 {
|
||||||
|
t.Fatalf("the release is %s: %s %+v", p.State, p.Note, p.Release)
|
||||||
|
}
|
||||||
|
if failed, _ := inv.GateFailed(ctx, "build-late-c2"); failed {
|
||||||
|
t.Fatal("a silent machine's build was marked failed")
|
||||||
|
}
|
||||||
|
if sent, _, _ := inv.SentBuilds(ctx, "laptop"); sent["late"] != "c2" {
|
||||||
|
t.Fatalf("laptop was put back: %v", sent)
|
||||||
|
}
|
||||||
|
obs, err := silentObservation(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(obs) != 1 || obs[0].Kind != "release-silent" || !slices.Equal(obs[0].Also, []string{"laptop"}) {
|
||||||
|
t.Fatalf("the silence is not said: %+v", obs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -451,6 +452,33 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
case "queue":
|
case "queue":
|
||||||
// As data, the table its output (novox/hq issue 455).
|
// As data, the table its output (novox/hq issue 455).
|
||||||
return []string{"queue", "--json"}, nil
|
return []string{"queue", "--json"}, nil
|
||||||
|
case "forget-checks":
|
||||||
|
if err := need("owner", "repo", "number"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{"forget-checks", str("owner"), str("repo"), str("number")}, nil
|
||||||
|
case "try":
|
||||||
|
// A branch tried before its pull request (novox/hq issue 686): asked, or read by the id it answered.
|
||||||
|
if id := str("id"); id != "" {
|
||||||
|
return []string{"try", "--id", id}, nil
|
||||||
|
}
|
||||||
|
if a.given["repository"] == "" && a.given["ref"] == "" {
|
||||||
|
// Neither shape named: the command says its usage, which names both, as `build` does.
|
||||||
|
return []string{"try"}, nil
|
||||||
|
}
|
||||||
|
if err := need("repository", "ref"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
argv := []string{"try", str("repository"), str("ref")}
|
||||||
|
for _, flag := range []string{"base", "tests"} {
|
||||||
|
if v := str(flag); v != "" {
|
||||||
|
argv = append(argv, "--"+flag, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if on("store-only") {
|
||||||
|
argv = append(argv, "--store-only")
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
case "cancel", "kill":
|
case "cancel", "kill":
|
||||||
if err := need("id"); err != nil {
|
if err := need("id"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -519,9 +547,13 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
}
|
}
|
||||||
return []string{"unpin", str("node"), str("provision")}, nil
|
return []string{"unpin", str("node"), str("provision")}, nil
|
||||||
case "push":
|
case "push":
|
||||||
// Sent and not waited for: the asker reads `status` for what the machine did, which is
|
// A push to a named machine waits for that machine's own answer, so the call's record says
|
||||||
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
|
// how it went: applied, refused with the machine's reason, or which resources failed and why
|
||||||
// time out on every machine that takes a minute, and say nothing about the ones that did not.
|
// (novox/hq issue 629). How long is the call's `wait`, else the controller's setting `push-wait`;
|
||||||
|
// neither given waits for nothing, as before. The caller is answered before the push starts
|
||||||
|
// (answersFirst) and the machines are released once sent, so the wait holds nobody; it is what
|
||||||
|
// `calls` then shows. A sweep (--behind) does not wait: the slowest machine would hold back
|
||||||
|
// what is said of the others.
|
||||||
// A push through the seat is a push by hand, and says why (novox/hq to-be 45 §7).
|
// A push through the seat is a push by hand, and says why (novox/hq to-be 45 §7).
|
||||||
if err := need("why"); err != nil {
|
if err := need("why"); err != nil {
|
||||||
return nil, fmt.Errorf("%w: a push by hand is a repair, recorded in the hand-act log with why", err)
|
return nil, fmt.Errorf("%w: a push by hand is a repair, recorded in the hand-act log with why", err)
|
||||||
@@ -534,7 +566,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
// behind is not read here: given with a machine, it is refused as passed over — naming
|
// behind is not read here: given with a machine, it is refused as passed over — naming
|
||||||
// a machine and asking for every machine behind are two requests, and guessing one
|
// a machine and asking for every machine behind are two requests, and guessing one
|
||||||
// would push a machine nobody named, or not push one somebody did.
|
// would push a machine nobody named, or not push one somebody did.
|
||||||
argv := []string{"push", n, "--wait", "0"}
|
wait, err := pushWait(str("wait"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
argv := []string{"push", n, "--wait", wait.String()}
|
||||||
// A running module's data moving is sent only when said, per module (novox/hq ADR 0217).
|
// A running module's data moving is sent only when said, per module (novox/hq ADR 0217).
|
||||||
if m := str("move"); m != "" {
|
if m := str("move"); m != "" {
|
||||||
argv = append(argv, "--move", m)
|
argv = append(argv, "--move", m)
|
||||||
@@ -1004,6 +1040,8 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co
|
|||||||
"delivery": true,
|
"delivery": true,
|
||||||
// The build queue, each ask with its position among the waiting (novox/hq issue 455).
|
// The build queue, each ask with its position among the waiting (novox/hq issue 455).
|
||||||
"queue": true,
|
"queue": true,
|
||||||
|
// A branch tried before its pull request (novox/hq issue 686).
|
||||||
|
"try": true,
|
||||||
// A plan followed (novox/hq issue 229): `plans wait` answers JSON; its other forms say text, and so answer no data.
|
// A plan followed (novox/hq issue 229): `plans wait` answers JSON; its other forms say text, and so answer no data.
|
||||||
"plans": true}
|
"plans": true}
|
||||||
|
|
||||||
@@ -1294,6 +1332,10 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
|||||||
link.AnswersWithin(ctx, bound+planWaitStart)
|
link.AnswersWithin(ctx, bound+planWaitStart)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if verb == "try" && len(argv) > 1 && argv[1] == "--id" {
|
||||||
|
// Reading a try holds its answer for the verdict, up to its bound (novox/hq issue 686, 229).
|
||||||
|
link.AnswersWithin(ctx, tryWait+planWaitStart)
|
||||||
|
}
|
||||||
if answer, read := readHere(ctx, argv); read {
|
if answer, read := readHere(ctx, argv); read {
|
||||||
return answer, nil
|
return answer, nil
|
||||||
}
|
}
|
||||||
@@ -1328,6 +1370,47 @@ var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
|
|||||||
// 0259 §8): the router asks it before an approval.
|
// 0259 §8): the router asks it before an approval.
|
||||||
"root-free": true}
|
"root-free": true}
|
||||||
|
|
||||||
|
// pushFileVar names the file the controller's push settings are written to (module.json, `push-wait`).
|
||||||
|
const pushFileVar = "MESH_PUSH_FILE"
|
||||||
|
|
||||||
|
// pushWait is how long a push to a named machine waits for its answer (novox/hq issue 629): the call's own
|
||||||
|
// `wait` when given, else the controller's setting `push-wait`, else nothing. No length is written here: the
|
||||||
|
// setting's default is the module's, and a setting that cannot be read waits for nothing, as a push did
|
||||||
|
// before, and is said.
|
||||||
|
func pushWait(given string) (time.Duration, error) {
|
||||||
|
if strings.TrimSpace(given) != "" {
|
||||||
|
d, ok := durationSetting(given)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("push: wait %q is no duration: say it as \"90s\", \"2m\", or \"0\" to wait for nothing", given)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
path := os.Getenv(pushFileVar)
|
||||||
|
if path == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "the push settings cannot be read (%v); a push waits for nothing\n", err)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var values map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &values); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "the push settings are not JSON (%v); a push waits for nothing\n", err)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
v, has := values["push-wait"]
|
||||||
|
if !has || v == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
d, ok := durationSetting(v)
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintf(os.Stderr, "the setting push-wait=%v is no duration; a push waits for nothing\n", v)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
// answersFirst is a command line whose caller is answered before it runs: a push, by its verb or
|
// answersFirst is a command line whose caller is answered before it runs: a push, by its verb or
|
||||||
// through `command`. A push sends the machine holding the bus first when its user list changed, the
|
// through `command`. A push sends the machine holding the bus first when its user list changed, the
|
||||||
// broker reloads, and a reload forgets every answer the bus was about to permit — so an answer
|
// broker reloads, and a reload forgets every answer the bus was about to permit — so an answer
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func TestAnArgumentAVerbDoesNotDeclareIsRefused(t *testing.T) {
|
|||||||
// naming one beside behind is refused rather than one of the two guessed.
|
// naming one beside behind is refused rather than one of the two guessed.
|
||||||
func TestAPushIsOneMachineOrSaysItIsTheWholeMesh(t *testing.T) {
|
func TestAPushIsOneMachineOrSaysItIsTheWholeMesh(t *testing.T) {
|
||||||
argv, err := argvFor("push", map[string]any{"node": "g1", "why": "w"})
|
argv, err := argvFor("push", map[string]any{"node": "g1", "why": "w"})
|
||||||
if err != nil || strings.Join(argv, " ") != "push g1 --wait 0 --why w" {
|
if err != nil || strings.Join(argv, " ") != "push g1 --wait 0s --why w" {
|
||||||
t.Fatalf("a named push: %v %v", argv, err)
|
t.Fatalf("a named push: %v %v", argv, err)
|
||||||
}
|
}
|
||||||
for _, args := range []map[string]any{{"why": "w"}, {"behind": "true", "why": "w"}} {
|
for _, args := range []map[string]any{{"why": "w"}, {"behind": "true", "why": "w"}} {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -164,12 +166,14 @@ func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A push and a build are sent, not waited for: the asker reads status, or the build's log by its
|
// Acts do not block the call. A build is sent, not waited for: the asker reads the build's log by its id.
|
||||||
// id, for what happened. A repository given as a forge path is said to be one (issue 176).
|
// A push answers its caller before it starts (answersFirst), so a named push waits for the machine's own
|
||||||
|
// answer without holding anybody, and the call's record says how it went (novox/hq issue 629). A
|
||||||
|
// repository given as a forge path is said to be one (issue 176).
|
||||||
func TestActsDoNotBlockTheCall(t *testing.T) {
|
func TestActsDoNotBlockTheCall(t *testing.T) {
|
||||||
argv, _ := argvFor("push", map[string]any{"node": "one", "why": "w"})
|
argv, _ := argvFor("push", map[string]any{"node": "one", "why": "w"})
|
||||||
if strings.Join(argv, " ") != "push one --wait 0 --why w" {
|
if strings.Join(argv, " ") != "push one --wait 0s --why w" || !answersFirst(argv) {
|
||||||
t.Fatalf("push waits: %v", argv)
|
t.Fatalf("a named push with no setting waits, or holds its caller: %v", argv)
|
||||||
}
|
}
|
||||||
argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"})
|
argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"})
|
||||||
if strings.Join(argv, " ") != "build novox/x --wait 0 --self --path modules/x" {
|
if strings.Join(argv, " ") != "build novox/x --wait 0 --self --path modules/x" {
|
||||||
@@ -460,3 +464,40 @@ func TestAQuotedValueRoundTrips(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A named push waits as long as the call says, else as long as the controller's setting push-wait says, else
|
||||||
|
// not at all: no length is the code's own (novox/hq issue 629).
|
||||||
|
func TestAPushWaitsAsTheCallOrTheSettingSays(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "push.json")
|
||||||
|
if err := os.WriteFile(file, []byte(`{"push-wait": "2m"}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(pushFileVar, file)
|
||||||
|
argv, err := argvFor("push", map[string]any{"node": "one", "why": "w"})
|
||||||
|
if err != nil || strings.Join(argv, " ") != "push one --wait 2m0s --why w" {
|
||||||
|
t.Fatalf("the setting was not the default: %v %v", argv, err)
|
||||||
|
}
|
||||||
|
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "30s"})
|
||||||
|
if err != nil || strings.Join(argv, " ") != "push one --wait 30s --why w" {
|
||||||
|
t.Fatalf("the call's wait did not win over the setting: %v %v", argv, err)
|
||||||
|
}
|
||||||
|
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "0"})
|
||||||
|
if err != nil || strings.Join(argv, " ") != "push one --wait 0s --why w" {
|
||||||
|
t.Fatalf("a wait of 0 did not wait for nothing: %v %v", argv, err)
|
||||||
|
}
|
||||||
|
if _, err := argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "soon"}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "no duration") {
|
||||||
|
t.Fatalf("a wait that is no duration was taken: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file, []byte(`{"push-wait": "later"}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w"})
|
||||||
|
if err != nil || strings.Join(argv, " ") != "push one --wait 0s --why w" {
|
||||||
|
t.Fatalf("an unreadable setting did not fall back to waiting for nothing: %v %v", argv, err)
|
||||||
|
}
|
||||||
|
argv, _ = argvFor("push", map[string]any{"why": "w"})
|
||||||
|
if strings.Join(argv, " ") != "push --behind --wait 0 --why w" {
|
||||||
|
t.Fatalf("a sweep waits: %v", argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,6 +79,37 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+
|
return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+
|
||||||
"through a verb (this line came through %q): nothing was read or sealed", verb)
|
"through a verb (this line came through %q): nothing was read or sealed", verb)
|
||||||
}
|
}
|
||||||
|
// **A provision's credential issued outside the mesh is given once, for its provider** (novox/hq ADR 0337 §3,
|
||||||
|
// issue 165): `secret accept <provider's machine> <provider module> <provision>`, sealed to every consumer of
|
||||||
|
// that provider; the per-pair form naming a consumer is refused for it, naming this one.
|
||||||
|
outsideAt, err := outsideOffer(ctx, module, name, *provider)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outsideAt == "" && *provider != "" {
|
||||||
|
if offered, err := offeredOutsideBy(ctx, name); err != nil {
|
||||||
|
return err
|
||||||
|
} else if offered != "" {
|
||||||
|
return fmt.Errorf("%s is issued outside the mesh and given once for its provider, which seals it to "+
|
||||||
|
"every consumer of it (ADR 0337): `secret accept %s %s %s`; nothing was read or sealed",
|
||||||
|
name, *provider, offered, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if outsideAt != "" {
|
||||||
|
if *desk != "" || *local != "" {
|
||||||
|
return errors.New("a provision's credential issued outside the mesh is given at the controller's " +
|
||||||
|
"terminal, for every consumer of its provider; it takes neither --at-desk nor --local")
|
||||||
|
}
|
||||||
|
value, err := valueFor(node, module, name, *from)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
value = asSupplied(value)
|
||||||
|
if value == "" {
|
||||||
|
return errors.New("there is nothing to seal")
|
||||||
|
}
|
||||||
|
return acceptForProvision(ctx, node, module, name, value)
|
||||||
|
}
|
||||||
if *desk != "" {
|
if *desk != "" {
|
||||||
if *from != "" || *provider != "" {
|
if *from != "" || *provider != "" {
|
||||||
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
|
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
|
||||||
@@ -173,6 +204,7 @@ const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause
|
|||||||
"secret ask <node> <module> <name> [--at <machine>]\n" +
|
"secret ask <node> <module> <name> [--at <machine>]\n" +
|
||||||
"secret issued <provision>\n" +
|
"secret issued <provision>\n" +
|
||||||
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
|
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
|
||||||
|
"secret accept <provider's node> <provider module> <provision> [--from <file>] (a credential issued outside the mesh, for every consumer)\n" +
|
||||||
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||||
"secret export [--out <file>]"
|
"secret export [--out <file>]"
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ type sendable struct {
|
|||||||
actingEpoch uint64
|
actingEpoch uint64
|
||||||
// modules are the modules this declaration names, for the record of the send; never on the wire.
|
// modules are the modules this declaration names, for the record of the send; never on the wire.
|
||||||
modules []string
|
modules []string
|
||||||
|
// localNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR
|
||||||
|
// 0340 §8), for plan to say; never on the wire.
|
||||||
|
localNetwork []catalogue.LocalNetworkPort
|
||||||
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
||||||
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
||||||
Adoption *adoptionEnvelope
|
Adoption *adoptionEnvelope
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0346, issue 624: a provider on another machine serves its consumers its own composed
|
||||||
|
// names, as that machine composes them; one it does not compose is left out, said under the need.
|
||||||
|
func TestAProviderElsewhereServesItsOwnComposedName(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
forge, err := catalogue.ParseManifest([]byte(`{"module":"forge","version":"1","requires":["route"],
|
||||||
|
"provides":[{"name":"npm-package-registry","scope":"mesh"}],
|
||||||
|
"contributes":{"route":{"web":{"label":"git","port":3000}}},
|
||||||
|
"serves":{"npm-package-registry":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/",
|
||||||
|
"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"}}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
register(t, open, catalogue.Manifest{Module: "proxy", Version: "1", Provides: catalogue.FromAnywhere("route"),
|
||||||
|
Receives: map[string]string{"route": "/var/lib/proxy/routes.json"}})
|
||||||
|
register(t, open, forge)
|
||||||
|
register(t, open, catalogue.Manifest{Module: "lab", Version: "1", Requires: []string{"npm-package-registry"},
|
||||||
|
Binds: map[string]string{"npm-package-registry": "/var/lib/lab/package-registry.json"}})
|
||||||
|
for _, a := range [][2]string{{"anchor", "proxy"}, {"anchor", "forge"}, {"laptop", "lab"}} {
|
||||||
|
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
|
||||||
|
t.Fatalf("assign %s %s: %v", a[0], a[1], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plan, _, err := planFor(ctx, open, "laptop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n := need(t, plan, "lab", "npm-package-registry")
|
||||||
|
if n.Serves["public-root"] != "https://git.example.test/api/packages/novox/npm/" || len(n.LeftOut) != 0 {
|
||||||
|
t.Fatalf("the consumer elsewhere is served the forge's public root: %v, left out %v", n.Serves, n.LeftOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := open.inventory.SetPublicDomain(ctx, "anchor", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plan, _, err = planFor(ctx, open, "laptop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n = need(t, plan, "lab", "npm-package-registry")
|
||||||
|
if _, there := n.Serves["public-root"]; there {
|
||||||
|
t.Fatalf("anchor composes no public name, so the key is left out: %v", n.Serves)
|
||||||
|
}
|
||||||
|
if len(n.LeftOut) != 1 || !strings.Contains(n.LeftOut[0], `without "public-root"`) {
|
||||||
|
t.Fatalf("and the need says so: %v", n.LeftOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An approved settings change is sent by a walk at once, and the operator's one Approve is its push (novox/hq ADR
|
||||||
|
// 0344, issue 641).
|
||||||
|
//
|
||||||
|
// On 2026-10-13 the operator approved a betterbird proposal on Telegram and nothing happened: the warrant set the
|
||||||
|
// layer, and the push stayed a separate act (ADR 0277 §18) that nobody took. Now the warrant that sets the layer
|
||||||
|
// opens a **settings walk**: the walk ADR 0236 opens for builds waiting for a gate, with a layer in place of the
|
||||||
|
// builds. Its order is every machine running the module that the layer reaches, the control node last; each is
|
||||||
|
// sent and its module judged there, from the machine's report on that send, before the next is sent. The send is
|
||||||
|
// the layer alone: every module there stays at the build its machine was last sent (keepingBuildsForSettings), so a
|
||||||
|
// build held for a person (ADR 0242) or waiting for a gate stays where it is — a policy of `record` holds a build,
|
||||||
|
// never a setting — and a send that would move a running module's data stops the walk (ADR 0217 §3). The walk says its start and end on `plan-moved` (ADR 0308), and the holder of the operator's
|
||||||
|
// channel tells its end once. A failure stops the walk and leaves the layer as approved; it holds back nothing
|
||||||
|
// else.
|
||||||
|
|
||||||
|
// settingsWalkPrefix names a settings walk's id.
|
||||||
|
const settingsWalkPrefix = "settings-"
|
||||||
|
|
||||||
|
// settingsWalkFor is what a settings walk says it is for, where a merge's walk says its repository.
|
||||||
|
func settingsWalkFor(s inventory.PlanSettings) string {
|
||||||
|
where := s.Node
|
||||||
|
if where == "" {
|
||||||
|
where = "the whole mesh"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("the settings of %s on %s", s.Module, where)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsWalkOrder is the machines a layer reaches, in the order a settings walk sends them: every machine
|
||||||
|
// running the module, or the one the layer names when it runs it there, sorted, the control node last. Pure.
|
||||||
|
func settingsWalkOrder(node string, running, controllers []string) []string {
|
||||||
|
var order, last []string
|
||||||
|
for _, n := range running {
|
||||||
|
if node != "" && n != node {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(controllers, n) {
|
||||||
|
last = append(last, n)
|
||||||
|
} else {
|
||||||
|
order = append(order, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(order)
|
||||||
|
sort.Strings(last)
|
||||||
|
return append(order, last...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSettingsWalk opens the walk that sends an approved layer, and answers the words of what it does: the
|
||||||
|
// machines it sends, or that no machine runs the module and nothing is sent. Called on the operator's warrant,
|
||||||
|
// after the layer is set.
|
||||||
|
var openSettingsWalk = func(ctx context.Context, open *stores, s inventory.PlanSettings) (string, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
running, err := inv.Running(ctx, s.Module)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
controllers, err := inv.Running(ctx, catalogue.ControllerSeatName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
order := settingsWalkOrder(s.Node, running, controllers)
|
||||||
|
if len(order) == 0 {
|
||||||
|
return fmt.Sprintf("no machine runs %s there, so there is nothing to send", s.Module), nil
|
||||||
|
}
|
||||||
|
release, err := inv.HoldPlans(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
p := inventory.Plan{ID: fmt.Sprintf("%s%d", settingsWalkPrefix, now.UnixNano()), Repository: settingsWalkFor(s),
|
||||||
|
Created: now, State: inventory.PlanRolling, Tiers: [][]string{{s.Module}}, Modules: map[string]*inventory.PlanModule{},
|
||||||
|
Release: &inventory.PlanRelease{Order: order, Settings: &s},
|
||||||
|
Note: fmt.Sprintf("approved (ask %s): sent to %s, one machine at a time, each judged", s.Ask,
|
||||||
|
strings.Join(order, ", "))}
|
||||||
|
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s — %s\n", p.ID, p.Repository, p.Note)
|
||||||
|
return fmt.Sprintf("%s sends it to %s, one machine at a time, each judged", p.ID, strings.Join(order, ", ")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsDataMoves is what a settings walk's send to a machine would move of a running module's data: composed as
|
||||||
|
// that send composes it, every build kept, and compared with what the machine was last sent (ADR 0217 §3).
|
||||||
|
var settingsDataMoves = func(ctx context.Context, open *stores, node string) ([]dataMove, error) {
|
||||||
|
keeping := keepingBuildsForSettings(keepingRecorded(ctx), node)
|
||||||
|
gens, err := generators(keeping, open)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plan, settings, err := planFor(keeping, open, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
declared, err := declarationWith(keeping, open, node, plan, settings, gens, Reading)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := declared.Body()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return heldMoves(keeping, open.inventory, node, body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgingOn is the open walk, other than the one named, whose gate is judging on a machine; empty when none is. A
|
||||||
|
// settings walk sends a machine only when no other walk judges there: its send would set aside the declaration
|
||||||
|
// that walk's gate reads the machine's report against.
|
||||||
|
var judgingOn = func(ctx context.Context, open *stores, self, node string) (string, error) {
|
||||||
|
plans, err := open.inventory.OpenPlans(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return judgingIn(plans, self, node), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgingIn is judgingOn over plans read. Pure.
|
||||||
|
func judgingIn(plans []inventory.Plan, self, node string) string {
|
||||||
|
judging := func(g *inventory.PlanGate) bool {
|
||||||
|
return g != nil && g.Verdict == "" && slices.Contains(g.Machines, node)
|
||||||
|
}
|
||||||
|
for _, p := range plans {
|
||||||
|
if p.ID == self || !p.Open() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Release != nil {
|
||||||
|
if judging(p.Release.Gate) {
|
||||||
|
return p.ID
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, s := range p.Modules {
|
||||||
|
if s != nil && judging(s.Gate) {
|
||||||
|
return p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceSettingsWalk takes one step of a settings walk: the next machine sent and its module judged there, or the
|
||||||
|
// machine being judged judged once more; done when every machine is.
|
||||||
|
func advanceSettingsWalk(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) {
|
||||||
|
r, s := p.Release, p.Release.Settings
|
||||||
|
now := time.Now().UTC()
|
||||||
|
wait := func(note string) (bool, error) {
|
||||||
|
changed := p.Note != note
|
||||||
|
p.Note = note
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
if r.Gate == nil {
|
||||||
|
heard, err := releaseHeard(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for r.Next < len(r.Order) {
|
||||||
|
node := r.Order[r.Next]
|
||||||
|
if !heard[node] {
|
||||||
|
r.Skipped = append(r.Skipped, node)
|
||||||
|
r.Next++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id, err := judgingOn(ctx, open, p.ID, node); err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if id != "" {
|
||||||
|
return wait(fmt.Sprintf("waiting before %s: %s is judging there", node, id))
|
||||||
|
}
|
||||||
|
// **A running module's data that would move is a person's word** (ADR 0217 §3): the walk stops before
|
||||||
|
// that machine, failed and said, and `push --move` sends it.
|
||||||
|
held, err := settingsDataMoves(ctx, open, node)
|
||||||
|
if errors.Is(err, errSettingsCannotKeep) {
|
||||||
|
return failBefore(p, node, err.Error())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("whether %s's data would move could not be read: %w", node, err)
|
||||||
|
}
|
||||||
|
if len(held) > 0 {
|
||||||
|
var said strings.Builder
|
||||||
|
sayDataHeld(&said, node, held)
|
||||||
|
return failBefore(p, node, "a running module's data would move there — "+
|
||||||
|
strings.Join(strings.Fields(said.String()), " "))
|
||||||
|
}
|
||||||
|
// A build that cannot be kept is refused by the composition above, which is the send's own, and fails the
|
||||||
|
// walk there: the send flattens its refusals into words, so it is not looked for again here.
|
||||||
|
sent, grants, err := sendJudged(keepingBuildsForSettings(ctx, node), open, node)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("sending %s %s: %w", node, settingsWalkFor(*s), err)
|
||||||
|
}
|
||||||
|
// **The module's build is the one the gate judges** (issue 641 review): kept by this send, it is what a
|
||||||
|
// crossing send — a merge's walk sending the machine a new build of the module — supersedes, so this gate
|
||||||
|
// never reads that send's report as its own.
|
||||||
|
kept := ""
|
||||||
|
if builds, known, err := open.inventory.SentBuilds(ctx, node); err == nil && known {
|
||||||
|
kept = builds[s.Module]
|
||||||
|
}
|
||||||
|
r.Gate = &inventory.PlanGate{Component: coreComponent(s.Module), Machines: sent, Since: &now,
|
||||||
|
From: kept, To: kept, Sent: sentNow(ctx, open.inventory, sent), Grants: grants}
|
||||||
|
p.Note = fmt.Sprintf("sent %s %s (ask %s), every build there kept; judging %s there", node,
|
||||||
|
settingsWalkFor(*s), s.Ask, s.Module)
|
||||||
|
if said := grantsSaid(grants); said != "" {
|
||||||
|
p.Note += "; " + said
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
p.State, p.Tier = inventory.PlanDone, len(p.Tiers)
|
||||||
|
p.Note = fmt.Sprintf("%s (ask %s) delivered on %s", settingsWalkFor(*s), s.Ask, strings.Join(r.Done, ", "))
|
||||||
|
if len(r.Done) == 0 {
|
||||||
|
// Sent nowhere is not delivered: said as the walk's failure, so the operator is not told it landed.
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = fmt.Sprintf("%s (ask %s) was sent to no machine", settingsWalkFor(*s), s.Ask)
|
||||||
|
}
|
||||||
|
if len(r.Skipped) > 0 {
|
||||||
|
p.Note += "; not heard from, left as they were, their next send carries it: " + strings.Join(r.Skipped, ", ")
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
g := r.Gate
|
||||||
|
node := r.Order[r.Next]
|
||||||
|
verdict, err := judgeMoves(ctx, open, g, []judged{{module: s.Module, node: node}}, now)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
switch verdict {
|
||||||
|
case "":
|
||||||
|
return wait(fmt.Sprintf("judging %s on %s: %s", s.Module, node, gateLine(g)))
|
||||||
|
case inventory.GatePassed:
|
||||||
|
r.Done = append(r.Done, node)
|
||||||
|
r.Next++
|
||||||
|
r.Gate = nil
|
||||||
|
return true, nil
|
||||||
|
case inventory.GateSuperseded:
|
||||||
|
p.State = inventory.PlanSuperseded
|
||||||
|
p.Note = fmt.Sprintf("superseded on %s: %s — nothing judged, nothing put back; the layer is set, and the "+
|
||||||
|
"machines not yet sent take it at their next send", node, g.Why)
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = fmt.Sprintf("%s failed its gate on %s: %s — the layer stays as the operator approved it, and the one it "+
|
||||||
|
"replaced is in `settings %s` history", settingsWalkFor(*s), node, g.Why, s.Module) + unreached(r)
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// failBefore ends a settings walk failed before it sent a machine, saying why, and that the layer stays.
|
||||||
|
func failBefore(p *inventory.Plan, node, why string) (bool, error) {
|
||||||
|
r, s := p.Release, p.Release.Settings
|
||||||
|
p.State = inventory.PlanFailed
|
||||||
|
p.Note = fmt.Sprintf("%s not sent to %s: %s; the layer stays as the operator approved it", settingsWalkFor(*s),
|
||||||
|
node, why) + unreached(r)
|
||||||
|
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unreached is what a stopped settings walk left without the layer, in words: the machines after the one it stopped
|
||||||
|
// at, and those passed over as away (ADR 0344 §6). Empty when there are none.
|
||||||
|
func unreached(r *inventory.PlanRelease) string {
|
||||||
|
var out string
|
||||||
|
if r.Next+1 < len(r.Order) {
|
||||||
|
out += "; not sent: " + strings.Join(r.Order[r.Next+1:], ", ")
|
||||||
|
}
|
||||||
|
if len(r.Skipped) > 0 {
|
||||||
|
out += "; not heard from, left as they were: " + strings.Join(r.Skipped, ", ")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,572 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0344, issue 641: the operator's one Approve on a settings proposal is the act that makes it take
|
||||||
|
// effect. The warrant sets the layer and opens a settings walk, which sends it to the machines it reaches one at a
|
||||||
|
// time, judging the module on each; the ask says so before the Approve, and nothing names a push.
|
||||||
|
|
||||||
|
// The order: every machine running the module, or the one the layer names, the control node last.
|
||||||
|
func TestASettingsWalkGoesToTheMachinesTheLayerReachesTheControlNodeLast(t *testing.T) {
|
||||||
|
running := []string{"shanks", "novox", "g14", "ace"}
|
||||||
|
for _, c := range []struct {
|
||||||
|
node string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"", []string{"ace", "g14", "shanks", "novox"}},
|
||||||
|
{"g14", []string{"g14"}},
|
||||||
|
{"novox", []string{"novox"}},
|
||||||
|
{"elsewhere", nil},
|
||||||
|
} {
|
||||||
|
if got := settingsWalkOrder(c.node, running, []string{"novox"}); !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Errorf("a layer on %q goes to %v, want %v", c.node, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A settings walk sends a machine only when no other walk judges there.
|
||||||
|
func TestASettingsWalkWaitsWhileAnotherWalkJudgesOnTheMachine(t *testing.T) {
|
||||||
|
judging := &inventory.PlanGate{Machines: []string{"g14"}}
|
||||||
|
passed := &inventory.PlanGate{Machines: []string{"g14"}, Verdict: inventory.GatePassed}
|
||||||
|
plans := []inventory.Plan{
|
||||||
|
{ID: "settings-1", State: inventory.PlanRolling, Release: &inventory.PlanRelease{Gate: judging}},
|
||||||
|
{ID: "release-2", State: inventory.PlanRolling, Release: &inventory.PlanRelease{Gate: passed}},
|
||||||
|
{ID: "plan-3", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{"app": {Gate: judging}}},
|
||||||
|
{ID: "plan-4", State: inventory.PlanDone, Modules: map[string]*inventory.PlanModule{"app": {Gate: judging}}},
|
||||||
|
}
|
||||||
|
if id := judgingIn(plans, "settings-1", "g14"); id != "plan-3" {
|
||||||
|
t.Errorf("on g14 %q judges, want plan-3 (itself, a passed gate and an ended walk are not judging)", id)
|
||||||
|
}
|
||||||
|
if id := judgingIn(plans[1:2], "x", "g14"); id != "" {
|
||||||
|
t.Errorf("a passed gate judges: %q", id)
|
||||||
|
}
|
||||||
|
if id := judgingIn(plans, "x", "shanks"); id != "" {
|
||||||
|
t.Errorf("on shanks %q judges", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ask says, before the Approve, that the layer is sent at once and judged, and that its end is told as every
|
||||||
|
// walk's is; neither the ask nor the proposer's answer names a push any more.
|
||||||
|
func TestTheAskSaysAnApprovedLayerIsSentAtOnce(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
in proposeInput
|
||||||
|
where string
|
||||||
|
had bool
|
||||||
|
}{
|
||||||
|
{proposeInput{module: "mounts", node: "shanks", values: mountsSources}, "shanks", false},
|
||||||
|
{proposeInput{module: "notes", values: map[string]any{"x": "1"}}, "every machine running notes", true},
|
||||||
|
{proposeInput{module: "notes", node: "laptop", clear: true}, "laptop", true},
|
||||||
|
} {
|
||||||
|
r := newProposerRig(t)
|
||||||
|
if c.had {
|
||||||
|
r.before, r.had = map[string]any{"x": "0"}, true
|
||||||
|
}
|
||||||
|
words, err := r.pr.propose(context.Background(), c.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
q := r.askSent(t)
|
||||||
|
if err := q.Check(r.now); err != nil {
|
||||||
|
t.Fatalf("the ask is refused: %v", err)
|
||||||
|
}
|
||||||
|
// At a glance (ADR 0345): the summary says it is sent at once by a walk, one machine at a time; the rest of
|
||||||
|
// what Approve does is the Details', which the router notes after the answers.
|
||||||
|
if !strings.Contains(q.Explanation, "sent at once by a walk to "+c.where+", one machine at a time, each judged") ||
|
||||||
|
!strings.Contains(q.Details, "and sent at once to "+c.where+", one machine at a time, each judged, "+
|
||||||
|
"every build there kept; its end is told as every walk's is") {
|
||||||
|
t.Errorf("the ask does not say the layer is sent at once to %s:\n%s", c.where, q.Explanation)
|
||||||
|
}
|
||||||
|
approve := q.Options[0]
|
||||||
|
if approve.Label != "Approve" || !strings.Contains(approve.Does, "sent to "+c.where+" at once") {
|
||||||
|
t.Errorf("Approve does %q", approve.Does)
|
||||||
|
}
|
||||||
|
if !strings.Contains(words, "a walk sends it to "+c.where+" at once") {
|
||||||
|
t.Errorf("the proposer's answer:\n%s", words)
|
||||||
|
}
|
||||||
|
for _, said := range []string{q.Explanation, q.Whole, approve.Does, words} {
|
||||||
|
if strings.Contains(said, "push") {
|
||||||
|
t.Errorf("a push is still named, as if a second act were needed:\n%s", said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- on the real stores -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The warrant sets the layer and opens the walk that sends it, naming the machines it reaches; a layer for a
|
||||||
|
// machine that does not run the module opens none and says so.
|
||||||
|
func TestAnApprovedLayerOpensTheWalkThatSendsIt(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644",
|
||||||
|
"content": "x = ${setting:x}\n"}}})
|
||||||
|
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A layer set by hand opens no walk: it still waits for a push (ADR 0344 §7).
|
||||||
|
if err := atTheTerminal(t, "settings", "set", "notes", `{"x":"0"}`, "--node", "laptop"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if plans, _ := open.inventory.OpenPlans(ctx); len(plans) != 0 {
|
||||||
|
t.Fatalf("a layer set at the terminal opened %d walk(s)", len(plans))
|
||||||
|
}
|
||||||
|
set := setLayerIn(open)
|
||||||
|
now := time.Now()
|
||||||
|
values := map[string]any{"x": "1"}
|
||||||
|
p := settingsProposal{Module: "notes", Values: values, From: "g14/claude-code", At: now,
|
||||||
|
Digest: layerDigest(values), BeforeDigest: layerDigest(nil)}
|
||||||
|
words, err := set(ctx, p, "s9", "approved by the operator (ask s9)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(words, "+ x (ask s9); settings-") || !strings.Contains(words, "sends it to laptop, one machine at a time") {
|
||||||
|
t.Errorf("the warrant's words: %q", words)
|
||||||
|
}
|
||||||
|
plans, err := open.inventory.OpenPlans(ctx)
|
||||||
|
if err != nil || len(plans) != 1 {
|
||||||
|
t.Fatalf("open walks: %+v %v", plans, err)
|
||||||
|
}
|
||||||
|
w := plans[0]
|
||||||
|
if w.Release == nil || w.Release.Settings == nil || w.Release.Settings.Module != "notes" || w.Release.Settings.Ask != "s9" ||
|
||||||
|
!reflect.DeepEqual(w.Release.Order, []string{"laptop"}) || w.Repository != "the settings of notes on the whole mesh" ||
|
||||||
|
!reflect.DeepEqual(w.Tiers, [][]string{{"notes"}}) {
|
||||||
|
t.Fatalf("the settings walk: %+v %+v", w, w.Release)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine's layer where the module does not run: set, and nothing to send.
|
||||||
|
q := settingsProposal{Module: "notes", Node: "anchor", Values: values, From: "g14/claude-code", At: now,
|
||||||
|
Digest: layerDigest(values), BeforeDigest: layerDigest(nil)}
|
||||||
|
words, err = set(ctx, q, "s10", "approved by the operator (ask s10)")
|
||||||
|
if err != nil || !strings.Contains(words, "no machine runs notes there, so there is nothing to send") {
|
||||||
|
t.Fatalf("a layer nobody runs: %q %v", words, err)
|
||||||
|
}
|
||||||
|
if plans, _ := open.inventory.OpenPlans(ctx); len(plans) != 1 {
|
||||||
|
t.Fatalf("a walk was opened for a machine that does not run the module: %d open", len(plans))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A walk that cannot be opened is the act's failure, said with the push that sends it by hand.
|
||||||
|
was := openSettingsWalk
|
||||||
|
openSettingsWalk = func(context.Context, *stores, inventory.PlanSettings) (string, error) {
|
||||||
|
return "", fmt.Errorf("the plans are not to be had")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { openSettingsWalk = was })
|
||||||
|
again := map[string]any{"x": "2"}
|
||||||
|
byHand := map[string]any{"x": "0"}
|
||||||
|
r := settingsProposal{Module: "notes", Node: "laptop", Values: again, Before: byHand, HadLayer: true,
|
||||||
|
From: "g14/claude-code", At: now, Digest: layerDigest(again), BeforeDigest: layerDigest(byHand)}
|
||||||
|
if _, err := set(ctx, r, "s11", "approved"); err == nil || !strings.Contains(err.Error(), "is set, and the walk that "+
|
||||||
|
"sends it could not be opened: the plans are not to be had — `push laptop` sends it") {
|
||||||
|
t.Errorf("an unopened walk: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkMesh is a mesh where `app` runs c1 on anchor and laptop and nothing waits for a gate: every send applied at
|
||||||
|
// once, recorded, and judged from the reports, with a machine named broken failing what it was sent.
|
||||||
|
type walkMesh struct {
|
||||||
|
open *stores
|
||||||
|
sent [][]string
|
||||||
|
broken map[string]bool
|
||||||
|
// kept says, per send, whether it kept every build of its machine; moves is the data a machine's send would move.
|
||||||
|
kept []bool
|
||||||
|
moves map[string][]dataMove
|
||||||
|
heard map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func aSettledMesh(t *testing.T) *walkMesh {
|
||||||
|
t.Helper()
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := open.inventory
|
||||||
|
w := &walkMesh{open: open, broken: map[string]bool{}, moves: map[string][]dataMove{},
|
||||||
|
heard: map[string]bool{"anchor": true, "laptop": true}}
|
||||||
|
withConditionsInMemory(t)
|
||||||
|
was := doctorFrom
|
||||||
|
doctorFrom = nil
|
||||||
|
t.Cleanup(func() { doctorFrom = was })
|
||||||
|
old := time.Now().Add(-2 * time.Hour)
|
||||||
|
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||||
|
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c1", Module: "app", Commit: "c1",
|
||||||
|
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: old, At: old,
|
||||||
|
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-1"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
|
||||||
|
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c1", Head: "c1", Asked: old}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, n := range []string{"anchor", "laptop"} {
|
||||||
|
if _, err := inv.Assign(ctx, n, "app"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RecordSent(ctx, nodeID(t, open, n), "d-"+n, map[string]string{"app": "c1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wasMoves, wasHeard, wasSend, wasGather, wasData := machineMoves, releaseHeard, sendRollout, gatherGateFacts, settingsDataMoves
|
||||||
|
settingsDataMoves = func(_ context.Context, _ *stores, node string) ([]dataMove, error) { return w.moves[node], nil }
|
||||||
|
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
|
||||||
|
modules, err := open.inventory.Assigned(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sent, known, err := open.inventory.SentBuilds(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.moves(node, modules, sent, known, all), nil
|
||||||
|
}
|
||||||
|
releaseHeard = func(context.Context, *stores) (map[string]bool, error) { return w.heard, nil }
|
||||||
|
n := 0
|
||||||
|
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||||
|
w.sent = append(w.sent, append([]string(nil), names...))
|
||||||
|
w.kept = append(w.kept, len(names) == 1 && settingsKeptOn(ctx, names[0]))
|
||||||
|
current, err := open.inventory.CurrentBuilds(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, node := range names {
|
||||||
|
modules, _ := open.inventory.Assigned(ctx, node)
|
||||||
|
carried := map[string]string{}
|
||||||
|
for _, m := range modules {
|
||||||
|
carried[m] = current[m].Commit
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
digest := fmt.Sprintf("d-%s-%d", node, n)
|
||||||
|
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest, carried); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := open.inventory.RecordDoing(ctx, nodeID(t, open, node), inventory.Doing{Node: node,
|
||||||
|
Outcome: inventory.OutcomeApplied, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
|
||||||
|
f := gateFacts{now: time.Now(), reports: map[string]inventory.Reported{}, engines: map[string]string{},
|
||||||
|
rolledBack: map[string][]lease.Rollback{}, served: map[string]served{}}
|
||||||
|
reports, err := open.inventory.LastReports(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
for _, r := range reports {
|
||||||
|
if w.broken[r.Node] {
|
||||||
|
r.Outcome = inventory.OutcomeFailed
|
||||||
|
}
|
||||||
|
f.reports[r.Node] = r
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
|
||||||
|
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
|
||||||
|
wasWindow := gateWitnessWindow
|
||||||
|
gateWitnessWindow = 0
|
||||||
|
t.Cleanup(func() { gateWitnessWindow = wasWindow })
|
||||||
|
gateWithin, gateEvery = time.Hour, time.Hour
|
||||||
|
t.Cleanup(func() {
|
||||||
|
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
|
||||||
|
settingsDataMoves = wasData
|
||||||
|
gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound
|
||||||
|
})
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *walkMesh) walk(t *testing.T) inventory.Plan {
|
||||||
|
t.Helper()
|
||||||
|
plans, err := w.open.inventory.RecentPlans(t.Context(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range plans {
|
||||||
|
if p.Release != nil && p.Release.Settings != nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("no settings walk")
|
||||||
|
return inventory.Plan{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The walk sends the first machine alone and judges the module there before it sends the next, and ends
|
||||||
|
// delivered on both; nothing waited for a gate, so each send carried the layer alone.
|
||||||
|
func TestASettingsWalkSendsOneMachineAtATimeEachJudged(t *testing.T) {
|
||||||
|
judgeLeavesAsTheCore(t)
|
||||||
|
w := aSettledMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
words, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s1", Change: "~ x"})
|
||||||
|
if err != nil || !strings.Contains(words, "sends it to anchor, laptop") {
|
||||||
|
t.Fatalf("%q %v", words, err)
|
||||||
|
}
|
||||||
|
advancePlans(ctx, w.open)
|
||||||
|
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
|
||||||
|
t.Fatalf("sent %v: the first machine alone, before it is judged", w.sent)
|
||||||
|
}
|
||||||
|
p := w.walk(t)
|
||||||
|
if p.Release.Gate == nil || p.Release.Next != 0 || !strings.Contains(p.Note, "judging app on anchor") {
|
||||||
|
t.Fatalf("the walk after its first send: %s %+v", p.Note, p.Release)
|
||||||
|
}
|
||||||
|
gateEvery = 0
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
advancePlans(ctx, w.open)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}, {"laptop"}}) {
|
||||||
|
t.Fatalf("sent %v", w.sent)
|
||||||
|
}
|
||||||
|
p = w.walk(t)
|
||||||
|
if p.State != inventory.PlanDone || !reflect.DeepEqual(p.Release.Done, []string{"anchor", "laptop"}) ||
|
||||||
|
p.Outcome() != inventory.WalkDelivered || !strings.Contains(p.Note, "delivered on anchor, laptop") {
|
||||||
|
t.Fatalf("the walk is %s: %s %+v", p.State, p.Note, p.Release)
|
||||||
|
}
|
||||||
|
// It said its start and its end, so the operator is told once (ADR 0308).
|
||||||
|
if p.Times == nil || p.Times.Started == nil || p.Times.Ended == nil {
|
||||||
|
t.Fatalf("the walk's start and end are not marked: %+v", p.Times)
|
||||||
|
}
|
||||||
|
// Nothing waits, and no walk of waiting builds opened beside it.
|
||||||
|
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r != nil {
|
||||||
|
t.Fatalf("%+v %v", r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine that fails what it was sent stops the walk there: the next is not sent, the layer stays, and the
|
||||||
|
// failure holds back no walk of waiting builds.
|
||||||
|
func TestAFailedSettingsWalkStopsAndHoldsNothingBack(t *testing.T) {
|
||||||
|
w := aSettledMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := w.open.inventory
|
||||||
|
w.broken["anchor"] = true
|
||||||
|
if err := inv.KeepSettings(ctx, "", "app", map[string]any{"x": "1"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
advancePlans(ctx, w.open)
|
||||||
|
}
|
||||||
|
p := w.walk(t)
|
||||||
|
if p.State != inventory.PlanFailed || p.Outcome() != inventory.WalkFailed ||
|
||||||
|
!strings.Contains(p.Note, "the settings of app on the whole mesh failed its gate on anchor") ||
|
||||||
|
!strings.Contains(p.Note, "the layer stays as the operator approved it") || !strings.Contains(p.Note, "not sent: laptop") {
|
||||||
|
t.Fatalf("the walk is %s: %s", p.State, p.Note)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
|
||||||
|
t.Fatalf("sent %v: anchor alone, nothing to laptop", w.sent)
|
||||||
|
}
|
||||||
|
if layer, has, err := inv.Layer(ctx, "", "app"); err != nil || !has || layer["x"] != "1" {
|
||||||
|
t.Fatalf("the layer after the failure: %v %v %v", layer, has, err)
|
||||||
|
}
|
||||||
|
// A build that waits for a gate is still walked: the failed settings walk holds nothing back.
|
||||||
|
w.broken["anchor"] = false
|
||||||
|
now := time.Now().Add(-time.Minute)
|
||||||
|
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||||
|
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
|
||||||
|
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
|
||||||
|
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
|
||||||
|
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r == nil {
|
||||||
|
t.Fatalf("the waiting build is held back by a failed settings walk: %+v %v", r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each send of a settings walk keeps every build of its machine: the layer goes, and a build held for a person or
|
||||||
|
// waiting for a gate stays where it is (ADR 0344 §3).
|
||||||
|
func TestASettingsWalkSendsTheLayerAloneEveryBuildKept(t *testing.T) {
|
||||||
|
w := aSettledMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := w.open.inventory
|
||||||
|
gateEvery = 0
|
||||||
|
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s3"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
advancePlans(ctx, w.open)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(w.kept, []bool{true, true}) || w.walk(t).State != inventory.PlanDone {
|
||||||
|
t.Fatalf("the sends kept every build: %v; the walk: %s", w.kept, w.walk(t).Note)
|
||||||
|
}
|
||||||
|
// What such a send keeps, on the store: a rolled-out build waiting there is kept at the one last sent.
|
||||||
|
now := time.Now().Add(-time.Minute)
|
||||||
|
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||||
|
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
|
||||||
|
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
|
||||||
|
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
|
||||||
|
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kept, err := recordedKept(keepingBuildsForSettings(ctx, "anchor"), w.open, "anchor")
|
||||||
|
if err != nil || kept["app"] != "c1" {
|
||||||
|
t.Fatalf("a settings send to anchor keeps %v (%v), want app at c1", kept, err)
|
||||||
|
}
|
||||||
|
if kept, _ := recordedKept(ctx, w.open, "anchor"); kept["app"] != "" {
|
||||||
|
t.Fatalf("an ordinary send keeps a rolled-out build: %v", kept)
|
||||||
|
}
|
||||||
|
// And so the ungated build waiting on anchor is no move of the settings send's: nothing for a gate to see.
|
||||||
|
f, err := readMoveFacts(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waiting, _ := machineMoves(ctx, w.open, f, "anchor", false)
|
||||||
|
if len(waiting) != 1 || waiting[0].Module != "app" {
|
||||||
|
t.Fatalf("anchor waits for %+v, want app's c2", waiting)
|
||||||
|
}
|
||||||
|
if moved, err := withoutKept(keepingBuildsForSettings(ctx, "anchor"), w.open, "anchor", waiting); err != nil || len(moved) != 0 {
|
||||||
|
t.Fatalf("a settings send to anchor would move %+v (%v)", moved, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine whose send would move a running module's data stops the walk there, failed and said with the push that
|
||||||
|
// sends it on a person's word (ADR 0217 §3); one not heard from is passed over and named; and a walk that reached
|
||||||
|
// no machine is not said delivered.
|
||||||
|
func TestASettingsWalkStopsBeforeADataMoveAndPassesOverAMachineAway(t *testing.T) {
|
||||||
|
w := aSettledMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
gateEvery = 0
|
||||||
|
w.moves["laptop"] = []dataMove{{Container: "app-server", Inside: "/data", From: "/srv/a", To: "/srv/b"}}
|
||||||
|
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s4"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
advancePlans(ctx, w.open)
|
||||||
|
}
|
||||||
|
p := w.walk(t)
|
||||||
|
if p.State != inventory.PlanFailed || !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) ||
|
||||||
|
!strings.Contains(p.Note, "not sent to laptop: a running module's data would move there") ||
|
||||||
|
!strings.Contains(p.Note, "push laptop --move app") {
|
||||||
|
t.Fatalf("sent %v; the walk is %s: %s", w.sent, p.State, p.Note)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := aSettledMesh(t)
|
||||||
|
v.heard = map[string]bool{"laptop": true}
|
||||||
|
gateEvery = 0
|
||||||
|
if _, err := openSettingsWalk(ctx, v.open, inventory.PlanSettings{Module: "app", Ask: "s5"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
advancePlans(ctx, v.open)
|
||||||
|
}
|
||||||
|
p = v.walk(t)
|
||||||
|
if p.State != inventory.PlanDone || !reflect.DeepEqual(p.Release.Skipped, []string{"anchor"}) ||
|
||||||
|
!strings.Contains(p.Note, "delivered on laptop; not heard from, left as they were, their next send carries it: anchor") {
|
||||||
|
t.Fatalf("the walk with anchor away is %s: %s", p.State, p.Note)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := aSettledMesh(t)
|
||||||
|
u.heard = map[string]bool{}
|
||||||
|
if _, err := openSettingsWalk(ctx, u.open, inventory.PlanSettings{Module: "app", Ask: "s6"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
advancePlans(ctx, u.open)
|
||||||
|
if p := u.walk(t); p.State != inventory.PlanFailed || p.Outcome() != inventory.WalkFailed ||
|
||||||
|
!strings.Contains(p.Note, "was sent to no machine") || len(u.sent) != 0 {
|
||||||
|
t.Fatalf("a walk that reached nobody is %s: %s", p.State, p.Note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate keeps the module's build its send kept, so a crossing send — a merge's walk sending the machine a new
|
||||||
|
// build of the module while the settings gate judges there — supersedes it, and its report is never read as the
|
||||||
|
// settings send's (issue 641 review, finding 1).
|
||||||
|
func TestACrossingSendSupersedesASettingsGate(t *testing.T) {
|
||||||
|
judgeLeavesAsTheCore(t)
|
||||||
|
w := aSettledMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := w.open.inventory
|
||||||
|
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s7"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
advancePlans(ctx, w.open)
|
||||||
|
p := w.walk(t)
|
||||||
|
if g := p.Release.Gate; g == nil || g.To != "c1" || g.From != "c1" {
|
||||||
|
t.Fatalf("the settings gate judges no build: %+v", p.Release.Gate)
|
||||||
|
}
|
||||||
|
// Another walk sends anchor app's c2 while the settings gate judges there.
|
||||||
|
now := time.Now().Add(-time.Minute)
|
||||||
|
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||||
|
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
|
||||||
|
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
|
||||||
|
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RecordSent(ctx, nodeID(t, w.open, "anchor"), "d-merge", map[string]string{"app": "c2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := inv.RecordDoing(ctx, nodeID(t, w.open, "anchor"), inventory.Doing{Node: "anchor",
|
||||||
|
Outcome: inventory.OutcomeApplied, Declared: "d-merge", Applied: 1, At: time.Now()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gateEvery = 0
|
||||||
|
advancePlans(ctx, w.open)
|
||||||
|
p = w.walk(t)
|
||||||
|
if p.State != inventory.PlanSuperseded || p.Release.Gate == nil || p.Release.Gate.Verdict != inventory.GateSuperseded ||
|
||||||
|
!strings.Contains(p.Note, "superseded on anchor") || len(p.Release.Done) != 0 {
|
||||||
|
t.Fatalf("the settings walk after a crossing send is %s: %s %+v", p.State, p.Note, p.Release)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
|
||||||
|
t.Fatalf("sent %v: laptop was sent after a superseded judging", w.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A send refusal that cannot clear by itself — what the machine was last sent is not known, or a build it runs is
|
||||||
|
// gone — fails the walk with why, rather than keeping it open and holding the walk of waiting builds; and a
|
||||||
|
// failure names the machines passed over as away (issue 641 review, findings 2 and 3).
|
||||||
|
func TestASettingsWalkFailsOnARefusalThatCannotClear(t *testing.T) {
|
||||||
|
w := aSettledMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := w.open.inventory
|
||||||
|
// What a send to a machine never sent can keep: nothing, and said as the refusal the walk fails on.
|
||||||
|
if _, err := inv.AddNode(ctx, "fresh"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := recordedKept(keepingBuildsForSettings(ctx, "fresh"), w.open, "fresh"); !errors.Is(err, errSettingsCannotKeep) {
|
||||||
|
t.Fatalf("a settings send to a machine never sent: %v", err)
|
||||||
|
}
|
||||||
|
w.heard = map[string]bool{"laptop": true}
|
||||||
|
settingsDataMoves = func(_ context.Context, _ *stores, node string) ([]dataMove, error) {
|
||||||
|
return nil, fmt.Errorf("%w: what %s was last sent is not known", errSettingsCannotKeep, node)
|
||||||
|
}
|
||||||
|
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s8"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
advancePlans(ctx, w.open)
|
||||||
|
p := w.walk(t)
|
||||||
|
if p.State != inventory.PlanFailed || len(w.sent) != 0 ||
|
||||||
|
!strings.Contains(p.Note, "not sent to laptop: the settings cannot be sent with every build kept: what laptop was last sent is not known") ||
|
||||||
|
!strings.Contains(p.Note, "not heard from, left as they were: anchor") {
|
||||||
|
t.Fatalf("the walk is %s: %s", p.State, p.Note)
|
||||||
|
}
|
||||||
|
// It is not open, so a build waiting for a gate is walked.
|
||||||
|
now := time.Now().Add(-time.Minute)
|
||||||
|
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||||
|
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
|
||||||
|
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
|
||||||
|
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
|
||||||
|
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r == nil {
|
||||||
|
t.Fatalf("the waiting build is held by the failed settings walk: %+v %v", r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -124,6 +125,11 @@ func openInventory(ctx context.Context) (*inventory.Inventory, error) {
|
|||||||
if aliases, err := inv.Aliases(ctx); err == nil {
|
if aliases, err := inv.Aliases(ctx); err == nil {
|
||||||
catalogue.UseAliases(aliases)
|
catalogue.UseAliases(aliases)
|
||||||
}
|
}
|
||||||
|
// And the list of predefined keys the mesh publishes (novox/hq ADR 0348 §7), which every manifest read again
|
||||||
|
// is judged against. A store a migration has not reached yet keeps none, and nothing names a key then.
|
||||||
|
if err := loadPredefined(ctx, inv); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "the list of predefined keys kept cannot be read: %v\n", err)
|
||||||
|
}
|
||||||
return inv, nil
|
return inv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A file's text kept as a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package T1,
|
||||||
|
// issue 654): `module check` and the merge gate read it from the tree beside the manifest, so a change to a
|
||||||
|
// template alone is judged as a change to its module.
|
||||||
|
|
||||||
|
const templatedNotifier = `{"module":"notifier","version":"1",
|
||||||
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||||
|
"resources":[{"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644","template":"files/notifier.conf"}]}`
|
||||||
|
|
||||||
|
// inlineNotifier is the same module as the mesh holds it: built, its text inline.
|
||||||
|
const inlineNotifier = `{"module":"notifier","version":"1",
|
||||||
|
"resources":[{"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644","content":"volume = 3\n"}]}`
|
||||||
|
|
||||||
|
// aTemplatedModule writes a module's directory: its manifest and its files/.
|
||||||
|
func aTemplatedModule(t *testing.T, dir, manifest string, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "module.json")
|
||||||
|
if err := os.WriteFile(path, []byte(manifest), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, text := range files {
|
||||||
|
full := filepath.Join(dir, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleCheckReadsTemplatesFromTheTreeBesideTheManifest(t *testing.T) {
|
||||||
|
good := aTemplatedModule(t, t.TempDir(), templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"})
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := moduleCheck([]string{good}, &out); err != nil || !strings.Contains(out.String(), "notifier: ok") {
|
||||||
|
t.Fatalf("a module with its text in files/ was refused: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A change to the template alone, to text the controller refuses inline, is refused the same.
|
||||||
|
bad := aTemplatedModule(t, t.TempDir(), templatedNotifier,
|
||||||
|
map[string]string{"files/notifier.conf": "volume = 3\nstore = ${dir:nowhere}\n"})
|
||||||
|
out.Reset()
|
||||||
|
if err := moduleCheck([]string{bad}, &out); err == nil || !strings.Contains(out.String(), "nowhere") {
|
||||||
|
t.Fatalf("a template whose text is refused inline passed:\n%s", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A symlink under files/ that no template names: the build's packing refuses it, so the check does too.
|
||||||
|
linked := t.TempDir()
|
||||||
|
aTemplatedModule(t, linked, templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"})
|
||||||
|
if err := os.Symlink("/etc/hostname", filepath.Join(linked, "files", "elsewhere")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
if err := moduleCheck([]string{filepath.Join(linked, "module.json")}, &out); err == nil ||
|
||||||
|
!strings.Contains(out.String(), "files/elsewhere is a symlink") {
|
||||||
|
t.Fatalf("a symlink under files/ passed the check:\n%s", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a template the tree lacks, by name.
|
||||||
|
missing := aTemplatedModule(t, t.TempDir(), templatedNotifier, nil)
|
||||||
|
out.Reset()
|
||||||
|
if err := moduleCheck([]string{missing}, &out); err == nil || !strings.Contains(out.String(), "files/notifier.conf") {
|
||||||
|
t.Fatalf("a missing template was not refused by name:\n%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// templatedMesh is catalogueMesh with the notifier on the laptop, registered as built: its text inline.
|
||||||
|
func templatedMesh(t *testing.T) (snapshot.Facts, map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
manifests := map[string]string{
|
||||||
|
"lemurs": `{"module":"lemurs","version":"1","capabilities":["systemd"],
|
||||||
|
"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`,
|
||||||
|
"notifier": inlineNotifier,
|
||||||
|
}
|
||||||
|
for name, raw := range manifests {
|
||||||
|
m, err := catalogue.ParseManifest([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog",
|
||||||
|
Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := assign(ctx, open, "laptop", name); err != nil {
|
||||||
|
t.Fatalf("assign %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return f, manifests
|
||||||
|
}
|
||||||
|
|
||||||
|
// aTemplatedTree is the catalogue's checkout with the notifier's text in files/.
|
||||||
|
func aTemplatedTree(t *testing.T, manifests map[string]string, text string, has bool) string {
|
||||||
|
t.Helper()
|
||||||
|
tree := aTree(t, withEdit(manifests, "notifier", ""))
|
||||||
|
files := map[string]string{}
|
||||||
|
if has {
|
||||||
|
files["files/notifier.conf"] = text
|
||||||
|
}
|
||||||
|
aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), templatedNotifier, files)
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifierTemplate = "modules/notifier/files/notifier.conf"
|
||||||
|
|
||||||
|
func TestTheMergeGateJudgesAChangeToATemplateAlone(t *testing.T) {
|
||||||
|
f, manifests := templatedMesh(t)
|
||||||
|
|
||||||
|
// The module moved to its template, the same text: nothing changes on any machine.
|
||||||
|
v := gateJudged(t, f, aTemplatedTree(t, manifests, "volume = 3\n", true), "modules/notifier/module.json",
|
||||||
|
notifierTemplate)
|
||||||
|
if v.Verdict == "fail" || v.Modules["notifier"] != "" {
|
||||||
|
t.Fatalf("the module moved to its template with the same text reads as a change (%v):\n%s", v.Modules, v.Report())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The template alone changed: its module changed, and composes.
|
||||||
|
v = gateJudged(t, f, aTemplatedTree(t, manifests, "volume = 4\n", true), notifierTemplate)
|
||||||
|
if v.Verdict == "fail" || v.Modules["notifier"] != "changed" {
|
||||||
|
t.Fatalf("a change to the template alone is not a change to its module (%v):\n%s", v.Modules, v.Report())
|
||||||
|
}
|
||||||
|
if v.Width == nil || strings.Join(v.Width.Modules, ",") != "notifier" {
|
||||||
|
t.Fatalf("a change to the template rebuilds %+v, not its module", v.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The template alone changed to text the controller refuses: the pull request fails, naming the module.
|
||||||
|
v = gateJudged(t, f, aTemplatedTree(t, manifests, "store = ${dir:nowhere}\n", true), notifierTemplate)
|
||||||
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), "notifier") {
|
||||||
|
t.Fatalf("a template the controller refuses passed the gate:\n%s", v.Report())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A symlinked files/ fails: nothing is read from outside the tree.
|
||||||
|
tree := aTree(t, withEdit(manifests, "notifier", ""))
|
||||||
|
elsewhere := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(elsewhere, "notifier.conf"), []byte("volume = 3\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), templatedNotifier, nil)
|
||||||
|
if err := os.Symlink(elsewhere, filepath.Join(tree, "modules", "notifier", "files")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v = gateJudged(t, f, tree, notifierTemplate)
|
||||||
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), "files/ is a symlink") {
|
||||||
|
t.Fatalf("a symlinked files/ passed the gate:\n%s", v.Report())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the template removed from the tree fails, naming it.
|
||||||
|
v = gateJudged(t, f, aTemplatedTree(t, manifests, "", false), notifierTemplate)
|
||||||
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), "files/notifier.conf") {
|
||||||
|
t.Fatalf("a missing template passed the gate:\n%s", v.Report())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the gate composes for the module in its template form is what the mesh composes for it inline.
|
||||||
|
func TestTheMergeGateResolvesATemplatedModuleToItsInlineTwin(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := aTemplatedModule(t, dir, templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"})
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err := catalogue.ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err = catalogue.WithTemplatesFrom(m, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resolved, err := standInBuild(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inline, err := catalogue.ParseManifest([]byte(inlineNotifier))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if builtAlike(resolved) != builtAlike(inline) {
|
||||||
|
got, _ := json.Marshal(resolved)
|
||||||
|
t.Fatalf("the templated module resolves to %s, not its inline twin", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "found.server",
|
||||||
|
"image": "registry.invalid:5000/library@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"networks": [
|
||||||
|
"predecessor_default"
|
||||||
|
],
|
||||||
|
"type": "container"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.access-storage/downloads",
|
||||||
|
"mode": "read",
|
||||||
|
"path": "/storage/downloads",
|
||||||
|
"type": "access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.access-storage/media/series",
|
||||||
|
"mode": "read",
|
||||||
|
"path": "/storage/media/series",
|
||||||
|
"type": "access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.config",
|
||||||
|
"mode": "0750",
|
||||||
|
"owner": "1001:2000",
|
||||||
|
"path": "/var/lib/library/config",
|
||||||
|
"type": "directory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "{\n \"accesses\": {\n \"downloads\": \"/storage/downloads\",\n \"series\": \"/storage/media/series\"\n },\n \"log\": \"warn\",\n \"places\": {\n \"config\": {\n \"owner\": \"1001:2000\",\n \"path\": \"/var/lib/library/config\"\n }\n },\n \"workers\": 4\n}\n",
|
||||||
|
"id": "library.runtime-config",
|
||||||
|
"mode": "0640",
|
||||||
|
"path": "/var/lib/library/config/runtime.json",
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.server",
|
||||||
|
"image": "registry.invalid:5000/library@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"type": "container",
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/library/config:/config",
|
||||||
|
"/storage/media/series:/series",
|
||||||
|
"/storage/downloads:/downloads:ro"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "library.access-home/media/series",
|
||||||
|
"mode": "read",
|
||||||
|
"path": "/home/media/series",
|
||||||
|
"type": "access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.access-storage/downloads",
|
||||||
|
"mode": "read",
|
||||||
|
"path": "/storage/downloads",
|
||||||
|
"type": "access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.config",
|
||||||
|
"mode": "0750",
|
||||||
|
"path": "/var/lib/library/config",
|
||||||
|
"type": "directory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "{\n \"accesses\": {\n \"downloads\": \"/storage/downloads\",\n \"series\": \"/home/media/series\"\n },\n \"log\": \"warn\"\n}\n",
|
||||||
|
"id": "library.runtime-config",
|
||||||
|
"mode": "0640",
|
||||||
|
"path": "/var/lib/library/config/runtime.json",
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.server",
|
||||||
|
"image": "registry.invalid:5000/library@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"type": "container",
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/library/config:/config",
|
||||||
|
"/home/media/series:/series",
|
||||||
|
"/storage/downloads:/downloads:ro"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -130,10 +130,14 @@ func aTierMesh(t *testing.T, modules ...string) *tierMesh {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { gatherGateFacts = wasGather })
|
t.Cleanup(func() { gatherGateFacts = wasGather })
|
||||||
|
|
||||||
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
|
wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
|
||||||
|
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
|
||||||
|
wasWindow := gateWitnessWindow
|
||||||
|
gateWitnessWindow = 0
|
||||||
|
t.Cleanup(func() { gateWitnessWindow = wasWindow })
|
||||||
// One judging per advance until a test says otherwise: a pass waits gateEvery for the next.
|
// One judging per advance until a test says otherwise: a pass waits gateEvery for the next.
|
||||||
gateSettle, gateEvery = 0, time.Hour
|
gateWithin, gateEvery = time.Hour, time.Hour
|
||||||
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound })
|
t.Cleanup(func() { gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound })
|
||||||
|
|
||||||
built := time.Now().UTC()
|
built := time.Now().UTC()
|
||||||
states := map[string]*inventory.PlanModule{}
|
states := map[string]*inventory.PlanModule{}
|
||||||
@@ -222,6 +226,9 @@ func TestACoreBehindWhileTheNodeToolsSettleFailsNoOtherModule(t *testing.T) {
|
|||||||
tm := aTierMesh(t, "app1", "app2", broker.RuntimeModule)
|
tm := aTierMesh(t, "app1", "app2", broker.RuntimeModule)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := tm.open.inventory
|
inv := tm.open.inventory
|
||||||
|
// The node tools are held inside their witness window, which the node-engine keeps while they settle: their
|
||||||
|
// one reading does not count yet, so the core behind raised meanwhile is theirs (novox/hq ADR 0351 decision 4).
|
||||||
|
gateWitnessWindow = time.Hour
|
||||||
|
|
||||||
advancePlans(ctx, tm.open)
|
advancePlans(ctx, tm.open)
|
||||||
if len(tm.sent) != 1 {
|
if len(tm.sent) != 1 {
|
||||||
@@ -405,7 +412,10 @@ func TestATierPutsABrokenModuleBackAtOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return f, err
|
return f, err
|
||||||
}
|
}
|
||||||
gateEvery, gateSettle, gateBound = 0, 100*time.Millisecond, 10*time.Second
|
// app1, judged as the core, counts a reading at most every 100 ms: it cannot have its three in the
|
||||||
|
// judgings that find the node tools broken, and is judged to its own pass after them (the hold the
|
||||||
|
// settle time gave before novox/hq ADR 0352 counted readings from the send).
|
||||||
|
gateEvery, gateWithin, gateBound = 100*time.Millisecond, time.Hour, 10*time.Second
|
||||||
for i := 0; i < 20 && len(seen) < 2; i++ {
|
for i := 0; i < 20 && len(seen) < 2; i++ {
|
||||||
advancePlans(ctx, tm.open)
|
advancePlans(ctx, tm.open)
|
||||||
}
|
}
|
||||||
@@ -440,15 +450,15 @@ func TestATierPutsABrokenModuleBackAtOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **A send carrying a core module waits for the core's readings** (issue 524 review): the leaf beside the node
|
// **A send carrying the tool runner waits for its witness window** (issue 524 review, novox/hq ADR 0351
|
||||||
// tools has its one reading at once, but the send is judged as the core: not before the settle time, and three
|
// decision 4): the leaf beside the node tools has its one reading at once, but the send is judged by the tool
|
||||||
// readings.
|
// runner's rule: one reading, not before the node-engine's witness window for it has ended.
|
||||||
func TestASendCarryingTheCoreIsJudgedAsTheCore(t *testing.T) {
|
func TestASendCarryingTheToolRunnerWaitsForItsWitnessWindow(t *testing.T) {
|
||||||
tm := aTierMesh(t, broker.RuntimeModule, "app1")
|
tm := aTierMesh(t, broker.RuntimeModule, "app1")
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
// One reading counted per advance, as every test with a gate left open does: a gate judged with no time
|
// One reading counted per advance, as every test with a gate left open does: a gate judged with no time
|
||||||
// between readings would count a pass at every look and never let an advance rest.
|
// between readings would count a pass at every look and never let an advance rest.
|
||||||
gateEvery, gateSettle = time.Hour, time.Hour
|
gateEvery, gateWitnessWindow = time.Hour, time.Hour
|
||||||
for i := 0; i < 6; i++ {
|
for i := 0; i < 6; i++ {
|
||||||
advancePlans(ctx, tm.open)
|
advancePlans(ctx, tm.open)
|
||||||
}
|
}
|
||||||
@@ -457,10 +467,10 @@ func TestASendCarryingTheCoreIsJudgedAsTheCore(t *testing.T) {
|
|||||||
if lead == nil {
|
if lead == nil {
|
||||||
lead = p.Modules["app1"].Gate
|
lead = p.Modules["app1"].Gate
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}}) || !p.Open() || lead == nil || lead.Verdict != "" || lead.Wants != gatePasses {
|
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}}) || !p.Open() || lead == nil || lead.Verdict != "" || lead.Wants != 1 {
|
||||||
t.Fatalf("passed before the core's settle time: sent %v, plan %s (%s), gate %+v", tm.sent, p.State, p.Note, lead)
|
t.Fatalf("passed inside the witness window: sent %v, plan %s (%s), gate %+v", tm.sent, p.State, p.Note, lead)
|
||||||
}
|
}
|
||||||
gateEvery, gateSettle = 0, 0
|
gateEvery, gateWitnessWindow = 0, 0
|
||||||
for i := 0; i < 6; i++ {
|
for i := 0; i < 6; i++ {
|
||||||
advancePlans(ctx, tm.open)
|
advancePlans(ctx, tm.open)
|
||||||
}
|
}
|
||||||
@@ -469,8 +479,9 @@ func TestASendCarryingTheCoreIsJudgedAsTheCore(t *testing.T) {
|
|||||||
t.Fatalf("the plan is %s (%s), sent %v", p.State, p.Note, tm.sent)
|
t.Fatalf("the plan is %s (%s), sent %v", p.State, p.Note, tm.sent)
|
||||||
}
|
}
|
||||||
for _, m := range []string{broker.RuntimeModule, "app1"} {
|
for _, m := range []string{broker.RuntimeModule, "app1"} {
|
||||||
if g := p.Modules[m].Gate; g != nil && (g.Verdict != inventory.GatePassed || g.Passes < gatePasses) {
|
if g := p.Modules[m].Gate; g != nil && (g.Verdict != inventory.GatePassed || g.Passes < 1 ||
|
||||||
t.Fatalf("%s's gate: %+v; want passed on %d readings", m, g, gatePasses)
|
!strings.Contains(g.Why, "the tool runner's rule")) {
|
||||||
|
t.Fatalf("%s's gate: %+v; want passed on one reading by the tool runner's rule", m, g)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A branch is tried before its pull request (novox/hq issue 686).
|
||||||
|
//
|
||||||
|
// A repository's store tests skip without a store, and no agent may start a database on its machine. So a
|
||||||
|
// builder pushed, a reviewer read, and only the pull request's merge check ran those tests for the first time:
|
||||||
|
// each failure there cost a round of fifteen to thirty minutes (mesh-controller #283, #310, #312).
|
||||||
|
//
|
||||||
|
// `try <owner>/<repository> <ref>` asks the build seat for the repository's own check of a pushed branch or
|
||||||
|
// commit, as the merge check runs it (internal/builder/check.go): the ref merged into the trunk's head, the
|
||||||
|
// trunk's merge-check.sh, each part in its toolchain's container, against a throwaway store and bus. It is a
|
||||||
|
// check and nothing more:
|
||||||
|
//
|
||||||
|
// - **no pull request, so no status**: its outcome is never said as `checked`, which is what the forge's
|
||||||
|
// holder sets statuses from (isTry, in Built);
|
||||||
|
// - **nothing registered, nothing built**: it names no module of the graph, so the merge gate does not run and
|
||||||
|
// no build is made for a merge (novox/hq ADR 0350's scratch builds are a pull request's check's alone);
|
||||||
|
// - **a check in the build queue**: after deliveries (link.KindOf, novox/hq issue 558), and bounded by the
|
||||||
|
// check's own bound (checkTimeout);
|
||||||
|
// - **the code a pull request's check would run anyway**: only a repository of the mesh, whose every pull
|
||||||
|
// request the build seat already checks, in containers that hold no runtime socket.
|
||||||
|
//
|
||||||
|
// A check outlasts a seat verb's one answer, so the ask answers its id at once and `try --id <id>` reads what
|
||||||
|
// came of it from the build's log, where the build seat says it whole (link.StepTried): holding its answer up
|
||||||
|
// to tryWait for a verdict, like `plans wait` (novox/hq issue 229). Nothing is kept beside the log.
|
||||||
|
|
||||||
|
// tryWait is how long reading a try holds its answer for the verdict. A variable so a test need not wait.
|
||||||
|
var tryWait = 20 * time.Second
|
||||||
|
|
||||||
|
// A ref as the forge is asked for it — a branch or a commit, never an option — and a `go test -run` pattern.
|
||||||
|
var (
|
||||||
|
tryRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
|
||||||
|
tryTests = regexp.MustCompile(`^[A-Za-z0-9_^$|().*+?/\[\]-]+$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// tryAnswer is what `try` answers, asking or reading.
|
||||||
|
type tryAnswer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
// State is asked (just now), waiting (nothing said of it yet: in the build queue), running, or the verdict:
|
||||||
|
// pass, warning (the repository declares no merge-check.sh), fail, or error (it could not run).
|
||||||
|
State string `json:"state"`
|
||||||
|
Said string `json:"said"`
|
||||||
|
Repository string `json:"repository,omitempty"`
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
|
Commit string `json:"commit,omitempty"`
|
||||||
|
Base string `json:"base,omitempty"`
|
||||||
|
Trunk string `json:"trunk,omitempty"`
|
||||||
|
On string `json:"on,omitempty"`
|
||||||
|
Took string `json:"took,omitempty"`
|
||||||
|
Failed []string `json:"failed,omitempty"`
|
||||||
|
// Last is the last thing the build seat said of one still running.
|
||||||
|
Last string `json:"last,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTry says a check's outcome is a try's: marked so, or — echoed by a build seat from before the mark — a
|
||||||
|
// check of no pull request and no group, which is nobody's status either.
|
||||||
|
func isTry(result link.BuildResult) bool {
|
||||||
|
c := result.Checked
|
||||||
|
return c != nil && (c.Try || (c.Number == 0 && c.Group == ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// asATry makes a check's ask a try's: the repository's own script alone, for no pull request.
|
||||||
|
func asATry(request link.BuildRequest, tests string, storeOnly bool) link.BuildRequest {
|
||||||
|
c := *request.Check
|
||||||
|
c.Number, c.Try, c.Tests, c.StoreOnly = 0, true, tests, storeOnly
|
||||||
|
c.Paths, c.Modules, c.New, c.Dependents, c.Manifests, c.Builds = nil, nil, nil, nil, nil, nil
|
||||||
|
c.Judge, c.Plan, c.Group, c.Members = "", nil, "", nil
|
||||||
|
request.Check = &c
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxTries is how many tries may wait or run at once, mesh-wide: each holds a build machine for minutes.
|
||||||
|
const maxTries = 4
|
||||||
|
|
||||||
|
// triesIn reads the build queue for a new try of one commit: the id of a try of that commit already waiting or
|
||||||
|
// running — the answer to a second ask — and whether there is room for another.
|
||||||
|
func triesIn(q link.Queue, owner, repo, commit string) (same string, room bool) {
|
||||||
|
n := 0
|
||||||
|
for _, a := range q.Asks {
|
||||||
|
if !link.IsTry(a.Request) || (a.State != link.AskWaiting && a.State != link.AskInFlight) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
c := a.Request.Check
|
||||||
|
if same == "" && strings.EqualFold(c.Owner, owner) && strings.EqualFold(c.Repo, repo) && a.Request.Ref == commit {
|
||||||
|
same = a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return same, n < maxTries
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgeCommit asks the forge, through its module's tools on the bus, which commit a branch or commit of a
|
||||||
|
// repository is, and the repository's default branch. A variable so a test needs no forge.
|
||||||
|
var forgeCommit = func(ctx context.Context, owner, repo, ref string) (commit, trunk string, err error) {
|
||||||
|
js, err := aBus()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
bus := link.OverNATS{Conn: js.Conn()}
|
||||||
|
ask := func(path string, into any) error {
|
||||||
|
raw, _ := json.Marshal(map[string]any{"path": path})
|
||||||
|
answer, err := link.Ask(ctx, bus, "gitea", "gitea_api", raw, 30*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if answer.Error != "" {
|
||||||
|
return errors.New(answer.Error)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(answer.Result, into)
|
||||||
|
}
|
||||||
|
var commits struct {
|
||||||
|
Result []struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := ask("/repos/"+owner+"/"+repo+"/commits?limit=1&stat=false&verification=false&files=false&sha="+
|
||||||
|
url.QueryEscape(ref), &commits); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if len(commits.Result) == 0 || commits.Result[0].SHA == "" {
|
||||||
|
return "", "", errors.New("the forge named no commit for it")
|
||||||
|
}
|
||||||
|
var found struct {
|
||||||
|
Result struct {
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := ask("/repos/"+owner+"/"+repo, &found); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return commits.Result[0].SHA, found.Result.DefaultBranch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryCommand is `try <owner>/<repository> <ref> [--base <branch>] [--tests <pattern>] [--store-only]`, and
|
||||||
|
// `try --id <id>` to read what came of one.
|
||||||
|
func tryCommand(ctx context.Context, args []string) error {
|
||||||
|
set := flag.NewFlagSet("try", flag.ContinueOnError)
|
||||||
|
base := set.String("base", "", "the branch it is merged into (the repository's default branch when none)")
|
||||||
|
tests := set.String("tests", "", "only the tests this `go test -run` pattern names")
|
||||||
|
storeOnly := set.Bool("store-only", false, "only the repository's store tests")
|
||||||
|
id := set.String("id", "", "a try's id: what came of it")
|
||||||
|
positionals, err := parseAround(set, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
usage := errors.New("try <owner>/<repository> <branch or commit> [--base <branch>] [--tests <pattern>] " +
|
||||||
|
"[--store-only], or try --id <id>")
|
||||||
|
if *id != "" {
|
||||||
|
if len(positionals) != 0 {
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
// Before it is a token of a bus subject: an id, never a pattern.
|
||||||
|
if !link.SafeBuildID(*id) {
|
||||||
|
return fmt.Errorf("%q is not a try's id", *id)
|
||||||
|
}
|
||||||
|
return tryRead(ctx, *id)
|
||||||
|
}
|
||||||
|
if len(positionals) != 2 {
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
owner, repo, isPath := strings.Cut(positionals[0], "/")
|
||||||
|
ref := positionals[1]
|
||||||
|
switch {
|
||||||
|
case !isPath || !tryRef.MatchString(owner) || !tryRef.MatchString(repo) || strings.Contains(repo, "/"):
|
||||||
|
return fmt.Errorf("%q is not a repository on the forge, owner/name; nothing was asked", positionals[0])
|
||||||
|
case !tryRef.MatchString(ref):
|
||||||
|
return fmt.Errorf("%q is not a branch or a commit; nothing was asked", ref)
|
||||||
|
case *base != "" && !tryRef.MatchString(*base):
|
||||||
|
return fmt.Errorf("%q is not a branch; nothing was asked", *base)
|
||||||
|
case *tests != "" && !tryTests.MatchString(*tests):
|
||||||
|
return fmt.Errorf("%q is not a `go test -run` pattern of test names; nothing was asked", *tests)
|
||||||
|
}
|
||||||
|
// **An unknown ref is refused here, loudly** — not sent to a machine to fail at its clone minutes later.
|
||||||
|
commit, trunk, err := forgeCommit(ctx, owner, repo, ref)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the forge holds no branch or commit %s in %s/%s — is it pushed? — so nothing was asked: %w",
|
||||||
|
ref, owner, repo, err)
|
||||||
|
}
|
||||||
|
if *base != "" {
|
||||||
|
trunk = *base
|
||||||
|
}
|
||||||
|
if trunk == "" {
|
||||||
|
return fmt.Errorf("the forge named no default branch for %s/%s, and no --base says what %s is merged into; "+
|
||||||
|
"nothing was asked", owner, repo, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
entries, read, edges, err := theGraph(ctx, open.inventory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p := link.PullUpdated{Owner: owner, Repo: repo, Base: trunk, Commit: commit}
|
||||||
|
// Whose repository it is, as a pull request's check reckons it: only the mesh's is tried, the code the
|
||||||
|
// build seat runs for every pull request already.
|
||||||
|
mesh := pullScope(p, entries, read, edges)
|
||||||
|
if !mesh.Mesh {
|
||||||
|
return fmt.Errorf("%s/%s is no repository of the mesh — no module is built from it and its owner is not "+
|
||||||
|
"the core's — and only the mesh's are tried on the build seat; nothing was asked", owner, repo)
|
||||||
|
}
|
||||||
|
request, err := checkRequestFor(ctx, open, p, checkScope{Mesh: true, From: mesh.From}, entries)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request = asATry(request, *tests, *storeOnly)
|
||||||
|
seat := buildSeatHeld(ctx)
|
||||||
|
// **Bounded in number** (novox/hq issue 686): one try of a commit at a time, and maxTries in all.
|
||||||
|
js, err := dialTheBus()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
queue, err := link.ReadQueue(ctx, js, seat)
|
||||||
|
js.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
same, room := triesIn(queue, owner, repo, commit)
|
||||||
|
if same != "" {
|
||||||
|
return printTry(tryAnswer{ID: same, State: "asked", Repository: owner + "/" + repo, Ref: ref, Commit: commit,
|
||||||
|
On: seat, Said: fmt.Sprintf("a try of %s/%s at %.12s already waits or runs, as %s: nothing new was asked. "+
|
||||||
|
"Read it with try id=%s", owner, repo, commit, same, same)})
|
||||||
|
}
|
||||||
|
if !room {
|
||||||
|
return fmt.Errorf("%d tries already wait or run on the build seat, the most at once (`queue` lists them); "+
|
||||||
|
"nothing was asked — read one of yours, or ask again when one has ended", maxTries)
|
||||||
|
}
|
||||||
|
ask, err := askOverOn(seat)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer ask.Close()
|
||||||
|
if err := ask.Ask(ctx, request); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return printTry(tryAnswer{ID: request.ID, State: "asked", Repository: owner + "/" + repo, Ref: ref, Commit: commit,
|
||||||
|
Base: trunk, On: seat, Said: fmt.Sprintf("asked %s to try %s/%s at %.12s (%s) merged into %s: its own "+
|
||||||
|
"merge-check.sh, as a check in the build queue, bounded at %s; no status is set and nothing is registered. "+
|
||||||
|
"Read it with try id=%s — a full run of this kind takes minutes", seat, owner, repo, commit, ref, trunk,
|
||||||
|
checkTimeout, request.ID)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryRead answers what came of a try, holding the answer up to tryWait for its verdict.
|
||||||
|
func tryRead(ctx context.Context, id string) error {
|
||||||
|
until := time.Now().Add(tryWait)
|
||||||
|
for {
|
||||||
|
lines, err := buildLines(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a := tryAnswerOf(id, lines)
|
||||||
|
if (a.State != "waiting" && a.State != "running") || !time.Now().Add(2*time.Second).Before(until) {
|
||||||
|
return printTry(a)
|
||||||
|
}
|
||||||
|
if err := sleepOrDone(ctx, 2*time.Second); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryAnswerOf reads a try's answer from what the build seat said of it: its outcome under StepTried; `failed`
|
||||||
|
// for one that could not run — an error, never a pass; else how far it is.
|
||||||
|
func tryAnswerOf(id string, lines []link.BuildLine) tryAnswer {
|
||||||
|
a := tryAnswer{ID: id, State: "waiting", Said: "nothing is said of " + id + " yet: it waits in the build queue " +
|
||||||
|
"(`queue` says where), or no try has that id; ask again"}
|
||||||
|
for _, l := range lines {
|
||||||
|
switch l.Step {
|
||||||
|
case link.StepTried:
|
||||||
|
var t link.Tried
|
||||||
|
if err := json.Unmarshal([]byte(l.Message), &t); err != nil || t.Verdict == "" {
|
||||||
|
return tryAnswer{ID: id, State: "error", Said: "the build seat's word on it cannot be read: " + l.Message}
|
||||||
|
}
|
||||||
|
return tryAnswer{ID: id, State: t.Verdict, Said: t.Summary, Took: t.Took, Commit: t.Head, Trunk: t.Trunk,
|
||||||
|
Failed: t.Failed}
|
||||||
|
case "failed":
|
||||||
|
return tryAnswer{ID: id, State: "error", Said: "it could not run: " + l.Message}
|
||||||
|
case "checked":
|
||||||
|
// A check that is no try, or a build seat from before the verb: its verdict is not a try's.
|
||||||
|
return tryAnswer{ID: id, State: "error", Said: "the build seat checked it and said no try's outcome — " +
|
||||||
|
"it predates the verb (novox/hq issue 686), or " + id + " is no try: " + l.Message + "; `builds` with " +
|
||||||
|
"the id says every line"}
|
||||||
|
}
|
||||||
|
a.State, a.Last = "running", l.Message
|
||||||
|
a.Said = "still running; ask again. `builds` with the id says every line so far"
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func printTry(a tryAnswer) error {
|
||||||
|
fmt.Fprintln(os.Stderr, a.Said)
|
||||||
|
for _, l := range a.Failed {
|
||||||
|
fmt.Fprintln(os.Stderr, " "+l)
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(a, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildLines is everything the build seat said of one build, as the bus keeps it, in order.
|
||||||
|
func buildLines(ctx context.Context, id string) ([]link.BuildLine, error) {
|
||||||
|
js, err := aBus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot reach the bus to read a build's log: %w", err)
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
subject := link.BuildLogOf("*", id)
|
||||||
|
sub, err := js.Context().PullSubscribe(subject, "",
|
||||||
|
nats.BindStream(broker.EventsStream), nats.DeliverAll(), nats.AckNone())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read %s from the bus: %w", subject, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = sub.Unsubscribe() }()
|
||||||
|
var lines []link.BuildLine
|
||||||
|
for {
|
||||||
|
batch, err := sub.Fetch(200, nats.MaxWait(2*time.Second))
|
||||||
|
if err != nil && !errors.Is(err, nats.ErrTimeout) && !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, fmt.Errorf("reading a build's log: %w", err)
|
||||||
|
}
|
||||||
|
for _, msg := range batch {
|
||||||
|
var line link.BuildLine
|
||||||
|
if json.Unmarshal(msg.Data, &line) == nil {
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(batch) < 200 {
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A branch is tried before its pull request (novox/hq issue 686): the repository's own check asked for a pushed
|
||||||
|
// branch or commit, with no pull request.
|
||||||
|
|
||||||
|
// What a try asks is a check — after deliveries in the build queue, bounded as one — of the repository's own
|
||||||
|
// script alone: no module of the graph, so no gate; nothing built for a merge, so no reusable image.
|
||||||
|
func TestATryAsksACheckOfTheRepositorysOwnScriptAlone(t *testing.T) {
|
||||||
|
request := link.BuildRequest{ID: "build-1", Ref: "c0ffee", Check: &link.CheckRequest{Owner: "novox", Repo: "a",
|
||||||
|
Number: 7, Base: "main", Modules: []string{"a"}, New: []string{"b"}, Manifests: []string{"module.json"},
|
||||||
|
Dependents: []string{"c"}, Builds: []link.CheckBuild{{Module: "a"}}, Judge: link.JudgeSelf,
|
||||||
|
Plan: &link.ChangePlan{}}}
|
||||||
|
tried := asATry(request, "^TestStore", true)
|
||||||
|
c := tried.Check
|
||||||
|
if !c.Try || c.Number != 0 || c.Tests != "^TestStore" || !c.StoreOnly {
|
||||||
|
t.Fatalf("not marked a try, or it kept a pull request's number: %+v", c)
|
||||||
|
}
|
||||||
|
if len(c.Modules)+len(c.New)+len(c.Manifests)+len(c.Dependents)+len(c.Builds) != 0 || c.Judge != "" || c.Plan != nil {
|
||||||
|
t.Fatalf("a try runs the gate or builds for a merge: %+v", c)
|
||||||
|
}
|
||||||
|
if c.Base != "main" || c.Owner != "novox" || tried.Ref != "c0ffee" {
|
||||||
|
t.Fatalf("a try lost what it merges into what: %+v", tried)
|
||||||
|
}
|
||||||
|
if link.KindOf(tried) != link.KindCheck {
|
||||||
|
t.Fatal("a try does not count as a check in the build queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A try's outcome is heard and left there: no `checked` is said, so the forge's holder sets no status, and
|
||||||
|
// nothing is recorded or registered (the handler holds no store here, and would fail reaching for one).
|
||||||
|
func TestATrysOutcomeSetsNoStatusAndRegistersNothing(t *testing.T) {
|
||||||
|
said := &saidOnTheBus{}
|
||||||
|
checkEvents = said
|
||||||
|
defer func() { checkEvents = nil }()
|
||||||
|
for name, result := range map[string]link.BuildResult{
|
||||||
|
"a try": {ID: "build-1", Ref: "c0ffee", Checked: &link.CheckRequest{Owner: "novox", Repo: "a", Try: true},
|
||||||
|
Check: &link.CheckOutcome{Verdict: "pass", RepoCheck: &link.CheckLayer{Verdict: "fail", Summary: "failed"}}},
|
||||||
|
"a try that could not run": {ID: "build-2", Checked: &link.CheckRequest{Owner: "novox", Repo: "a", Try: true},
|
||||||
|
Failed: "cannot clone"},
|
||||||
|
// A build seat from before the mark echoes the ask without it: a check of no pull request and no
|
||||||
|
// group is still nobody's status.
|
||||||
|
"a try echoed by an older build seat": {ID: "build-3", Checked: &link.CheckRequest{Owner: "novox", Repo: "a"},
|
||||||
|
Check: &link.CheckOutcome{Verdict: "pass"}},
|
||||||
|
} {
|
||||||
|
if err := (builds{}).Built(context.Background(), result); err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(said.events) != 0 {
|
||||||
|
t.Fatalf("a try's outcome was said for the forge: %v", said.events)
|
||||||
|
}
|
||||||
|
// A pull request's check is still said.
|
||||||
|
_ = (builds{}).Built(context.Background(), link.BuildResult{ID: "build-4", Checked: &link.CheckRequest{
|
||||||
|
Owner: "novox", Repo: "a", Number: 7}, Check: &link.CheckOutcome{Verdict: "pass"}})
|
||||||
|
if len(said.events) != 1 {
|
||||||
|
t.Fatalf("a pull request's check was said %d time(s)", len(said.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saidOnTheBus keeps the seat events said, and nothing else.
|
||||||
|
type saidOnTheBus struct {
|
||||||
|
link.Bus
|
||||||
|
events []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *saidOnTheBus) PublishSeatEvent(_ context.Context, seat, event string, _ []byte) error {
|
||||||
|
s.events = append(s.events, seat+" "+event)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ref the forge does not hold is refused before anything is asked, naming it.
|
||||||
|
func TestATryOfAnUnknownRefIsRefusedLoudly(t *testing.T) {
|
||||||
|
was := forgeCommit
|
||||||
|
defer func() { forgeCommit = was }()
|
||||||
|
forgeCommit = func(_ context.Context, owner, repo, ref string) (string, string, error) {
|
||||||
|
return "", "", errors.New("404 object does not exist [id: " + ref + "]")
|
||||||
|
}
|
||||||
|
err := tryCommand(context.Background(), []string{"novox/mesh-controller", "fix/no-such-branch"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a try of a ref the forge does not hold was asked")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"fix/no-such-branch", "novox/mesh-controller", "nothing was asked", "404"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the refusal does not say %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"mesh-controller", "main"}, {"novox/mesh-controller"}, {"novox/a", "--upload-pack=x"},
|
||||||
|
{"novox/a", "main", "--tests", "x; rm -rf /"}, {"novox/a", "main", "--id", "build-1"}} {
|
||||||
|
if err := tryCommand(context.Background(), args); err == nil {
|
||||||
|
t.Errorf("try %v was taken", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The answer of a try carries the lines that name what failed (novox/hq issue 460's shape) and how long it took.
|
||||||
|
func TestATrysAnswerCarriesTheFailingLines(t *testing.T) {
|
||||||
|
tried, _ := json.Marshal(link.Tried{Verdict: "fail", Summary: "its merge-check.sh failed: TestX", Took: "5m3s",
|
||||||
|
Head: "c0ffee", Trunk: "ca3f43c", Failed: []string{"--- FAIL: TestX (0.01s)", " x_test.go:12: got 1, want 2",
|
||||||
|
"FAIL\tgithub.com/novox/a/cmd\t3.1s"}})
|
||||||
|
lines := []link.BuildLine{{Seq: 1, Step: "check", Message: "novox/a#0 at c0ffee"},
|
||||||
|
{Seq: 2, Step: "output", Message: "ok github.com/novox/a/internal 1.2s"},
|
||||||
|
{Seq: 3, Step: link.StepTried, Message: string(tried)}, {Seq: 4, Step: "checked", Message: "pass: x"}}
|
||||||
|
a := tryAnswerOf("build-1", lines)
|
||||||
|
if a.State != "fail" || a.Took != "5m3s" || len(a.Failed) != 3 || a.Failed[0] != "--- FAIL: TestX (0.01s)" ||
|
||||||
|
a.Trunk != "ca3f43c" || a.ID != "build-1" {
|
||||||
|
t.Fatalf("the answer lost the verdict, the time or the failing lines: %+v", a)
|
||||||
|
}
|
||||||
|
// Not answered yet: said as running, with how far it is — never as a pass.
|
||||||
|
if a := tryAnswerOf("build-1", lines[:2]); a.State != "running" || a.Last == "" {
|
||||||
|
t.Fatalf("a try still running: %+v", a)
|
||||||
|
}
|
||||||
|
if a := tryAnswerOf("build-1", nil); a.State != "waiting" {
|
||||||
|
t.Fatalf("a try nothing was said of: %+v", a)
|
||||||
|
}
|
||||||
|
// One that could not run is an error with the build seat's word, never a pass.
|
||||||
|
if a := tryAnswerOf("build-1", append(lines[:1:1], link.BuildLine{Seq: 2, Step: "failed",
|
||||||
|
Message: "cannot clone"})); a.State != "error" || !strings.Contains(a.Said, "cannot clone") {
|
||||||
|
t.Fatalf("a try that could not run: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One try of a commit at a time, and four in all (novox/hq issue 686): a second ask is answered the first's id,
|
||||||
|
// a fifth is refused; a try that ended, and a pull request's check, count for nothing.
|
||||||
|
func TestTriesAreBoundedInNumber(t *testing.T) {
|
||||||
|
ask := func(id, repo, ref, state string, try bool) link.QueuedAsk {
|
||||||
|
return link.QueuedAsk{ID: id, State: state, Request: link.BuildRequest{ID: id, Ref: ref,
|
||||||
|
Check: &link.CheckRequest{Owner: "novox", Repo: repo, Try: try}}}
|
||||||
|
}
|
||||||
|
q := link.Queue{Asks: []link.QueuedAsk{
|
||||||
|
ask("t1", "a", "c1", link.AskInFlight, true), ask("t2", "a", "c2", link.AskWaiting, true),
|
||||||
|
ask("t3", "b", "c1", link.AskDead, true), ask("p1", "a", "c9", link.AskWaiting, false)}}
|
||||||
|
if same, room := triesIn(q, "novox", "a", "c2"); same != "t2" || !room {
|
||||||
|
t.Fatalf("a second try of a commit: %q %v", same, room)
|
||||||
|
}
|
||||||
|
if same, room := triesIn(q, "novox", "b", "c1"); same != "" || !room {
|
||||||
|
t.Fatalf("a dead try, or another repository's, was taken for this one: %q %v", same, room)
|
||||||
|
}
|
||||||
|
if same, _ := triesIn(q, "novox", "a", "c9"); same != "" {
|
||||||
|
t.Fatalf("a pull request's check was taken for a try: %q", same)
|
||||||
|
}
|
||||||
|
q.Asks = append(q.Asks, ask("t4", "c", "c3", link.AskWaiting, true), ask("t5", "d", "c4", link.AskWaiting, true))
|
||||||
|
if _, room := triesIn(q, "novox", "e", "c5"); room {
|
||||||
|
t.Fatal("a fifth try was let in")
|
||||||
|
}
|
||||||
|
// An id is checked before it is a token of a bus subject.
|
||||||
|
for _, id := range []string{"x.>", "*", "a b"} {
|
||||||
|
if err := tryCommand(context.Background(), []string{"--id", id}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "not a try's id") {
|
||||||
|
t.Errorf("try --id %q: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verb runs its command, and is declared with the shell habit it replaces (novox/hq ADR 0245).
|
||||||
|
func TestTheTryVerbRunsItsCommand(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
args map[string]any
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{map[string]any{"repository": "novox/a", "ref": "fix/x"}, []string{"try", "novox/a", "fix/x"}},
|
||||||
|
{map[string]any{"repository": "novox/a", "ref": "fix/x", "base": "main", "tests": "^TestX$", "store-only": "true"},
|
||||||
|
[]string{"try", "novox/a", "fix/x", "--base", "main", "--tests", "^TestX$", "--store-only"}},
|
||||||
|
{map[string]any{"id": "build-1"}, []string{"try", "--id", "build-1"}},
|
||||||
|
} {
|
||||||
|
got, err := argvFor("try", c.args)
|
||||||
|
if err != nil || !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Errorf("try %v: %v %v, want %v", c.args, got, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := argvFor("try", map[string]any{"repository": "novox/a"}); err == nil {
|
||||||
|
t.Error("a try of no ref was taken")
|
||||||
|
}
|
||||||
|
for _, v := range catalogue.ControllerVerbs {
|
||||||
|
if v.Name == "try" {
|
||||||
|
if len(v.Replaces) == 0 {
|
||||||
|
t.Error("try names no shell habit it replaces")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Error("try is not a verb of the controller seat")
|
||||||
|
}
|
||||||
@@ -160,7 +160,7 @@ func TestTheVerbsCarryReadReplaceAndMove(t *testing.T) {
|
|||||||
{"settings", map[string]any{"module": "plex", "history": "true"}, []string{"settings", "show", "plex", "--history"}},
|
{"settings", map[string]any{"module": "plex", "history": "true"}, []string{"settings", "show", "plex", "--history"}},
|
||||||
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}},
|
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}},
|
||||||
{"push", map[string]any{"node": "home", "move": "plex", "why": "w"},
|
{"push", map[string]any{"node": "home", "move": "plex", "why": "w"},
|
||||||
[]string{"push", "home", "--wait", "0", "--move", "plex", "--why", "w"}},
|
[]string{"push", "home", "--wait", "0s", "--move", "plex", "--why", "w"}},
|
||||||
{"push", map[string]any{"why": "w"}, []string{"push", "--behind", "--wait", "0", "--why", "w"}},
|
{"push", map[string]any{"why": "w"}, []string{"push", "--behind", "--wait", "0", "--why", "w"}},
|
||||||
{"plan", map[string]any{"node": "home", "diff": "true"}, []string{"plan", "home", "--diff"}},
|
{"plan", map[string]any{"node": "home", "diff": "true"}, []string{"plan", "home", "--diff"}},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func (f following) Upgraded(ctx context.Context, u link.Upgraded) error {
|
|||||||
// `rebuild`, a `replay` registered — was sent here one machine after another without any judging; it
|
// `rebuild`, a `replay` registered — was sent here one machine after another without any judging; it
|
||||||
// now waits for a gate like any other build, and the release plan walks it through the machines, one
|
// now waits for a gate like any other build, and the release plan walks it through the machines, one
|
||||||
// at a time, each judged.
|
// at a time, each judged.
|
||||||
fmt.Printf("%s moved to %s outside a plan; a release plan sends it to %s, one machine at a time, each judged "+
|
fmt.Printf("%s moved to %s outside a plan; a release plan sends it to %s, one machine first and then the rest at once, each judged "+
|
||||||
"(`upgrade backlog` lists what waits)\n", u.Module, shortCommit(u.Commit), readableList(on))
|
"(`upgrade backlog` lists what waits)\n", u.Module, shortCommit(u.Commit), readableList(on))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,12 @@ func walkReached(p inventory.Plan, running func(string) []string) map[string][]s
|
|||||||
reached = appendNew(reached, r.Gate.Machines...)
|
reached = appendNew(reached, r.Gate.Machines...)
|
||||||
carried(r.Gate)
|
carried(r.Gate)
|
||||||
}
|
}
|
||||||
|
for _, g := range r.Gates {
|
||||||
|
if g != nil && !g.Pending {
|
||||||
|
reached = appendNew(reached, g.Machines...)
|
||||||
|
carried(g)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, tier := range p.Tiers {
|
for _, tier := range p.Tiers {
|
||||||
for _, m := range tier {
|
for _, m := range tier {
|
||||||
for _, n := range running(m) {
|
for _, n := range running(m) {
|
||||||
|
|||||||
@@ -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.20261010191001-33917f91ac3c
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||||
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
|
||||||
@@ -36,4 +36,4 @@ require (
|
|||||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||||
// `go mod vendor` fails loudly, at once, everywhere.
|
// `go mod vendor` fails loudly, at once, everywhere.
|
||||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f
|
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck2pe4EciTYX5NsrPsHrTLENGNIWYNTU=
|
git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc h1:h5v2AbdjM/clMY1YxS9p7VuyYtIjkcw85w9Oz1cOYQc=
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
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=
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Listing and letting go of tagged documents: the check builds the scratch namespace keeps (novox/hq ADR 0350
|
||||||
|
// §5). A check build is found by its tag, and the build seat's holder lets each go when its pull request closes
|
||||||
|
// or it is seven days old, so the store keeps a week of check builds and no more.
|
||||||
|
|
||||||
|
// Tags is every tag repository holds, in the store's order. None for a repository the store does not hold.
|
||||||
|
func (s Store) Tags(ctx context.Context, repository string) ([]string, error) {
|
||||||
|
if s.Address == "" {
|
||||||
|
return nil, fmt.Errorf("this mesh has no artifact store on its network to list %s in", repository)
|
||||||
|
}
|
||||||
|
var tags []string
|
||||||
|
next := "http://" + s.Address + "/v2/" + repository + "/tags/list?n=1000"
|
||||||
|
for next != "" {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
response, err := s.client().Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
|
||||||
|
}
|
||||||
|
switch response.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
case http.StatusNotFound:
|
||||||
|
response.Body.Close()
|
||||||
|
return tags, nil
|
||||||
|
default:
|
||||||
|
response.Body.Close()
|
||||||
|
return nil, fmt.Errorf("the artifact store answered %s for the tags of %s", response.Status, repository)
|
||||||
|
}
|
||||||
|
var page struct {
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(&page)
|
||||||
|
link := response.Header.Get("Link")
|
||||||
|
response.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the tags of %s could not be read: %w", repository, err)
|
||||||
|
}
|
||||||
|
tags = append(tags, page.Tags...)
|
||||||
|
next = nextPage(link, s.Address)
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextPage is the registry's `Link: </v2/…>; rel="next"`, made whole; empty when there is no next page.
|
||||||
|
func nextPage(link, address string) string {
|
||||||
|
if !strings.Contains(link, `rel="next"`) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
start, end := strings.Index(link, "<"), strings.Index(link, ">")
|
||||||
|
if start < 0 || end <= start+1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
target := link[start+1 : end]
|
||||||
|
if strings.HasPrefix(target, "/") {
|
||||||
|
return "http://" + address + target
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTagged lets go of the document tag names in repository: its manifest, by digest, so the next
|
||||||
|
// collection takes its layer. Gone when the tag names nothing.
|
||||||
|
func (s Store) DeleteTagged(ctx context.Context, repository, tag string) error {
|
||||||
|
if s.Address == "" {
|
||||||
|
return fmt.Errorf("this mesh has no artifact store on its network to let go of %s:%s in", repository, tag)
|
||||||
|
}
|
||||||
|
digest, err := s.manifestDigest(ctx, repository, tag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if digest == "" {
|
||||||
|
return Gone
|
||||||
|
}
|
||||||
|
err = s.remove(ctx, s.url(repository, "manifests", digest), repository+":"+tag)
|
||||||
|
if errors.Is(err, Gone) {
|
||||||
|
return Gone
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -40,6 +40,39 @@ var (
|
|||||||
AskedBucket = BucketName(ControllerSeat, "asked")
|
AskedBucket = BucketName(ControllerSeat, "asked")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The controller's record mirrors (novox/hq ADR 0289, design 54, design 57 package F3): a bucket per kind of
|
||||||
|
// record, one key per record, written from the store by the lease holder only when the record changed, with
|
||||||
|
// one event on the controller's seat per write. Read by whatever names them in `reads` as
|
||||||
|
// `mesh-controller.<mirror>` (ADR 0201 §1), written by nobody else. **No secret in either** (ADR 0201): an
|
||||||
|
// assignment's value names its module and node; a module's declared config is what its manifest, which is
|
||||||
|
// public, declares.
|
||||||
|
//
|
||||||
|
// AssignmentsBucket holds which module is assigned to which node, keyed `<node>.<module>`; and
|
||||||
|
// DeclaredConfigBucket each registered module's config declarations as its manifest holds them, keyed by
|
||||||
|
// the module: what mesh-config resolves a config value from (ADR 0348 clauses 2 and 4).
|
||||||
|
var (
|
||||||
|
AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments)
|
||||||
|
DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig)
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`.
|
||||||
|
const (
|
||||||
|
MirrorAssignments = "assignments"
|
||||||
|
MirrorDeclaredConfig = "declared-config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ControllerMirrors are the local names of the controller's record mirrors, which a module may read.
|
||||||
|
func ControllerMirrors() []string { return []string{MirrorAssignments, MirrorDeclaredConfig} }
|
||||||
|
|
||||||
|
// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket.
|
||||||
|
const (
|
||||||
|
MirrorValueBytes = 64 << 10
|
||||||
|
MirrorBytes = 64 << 20
|
||||||
|
// MirrorMarkerTTL is the longest a purge's marker is kept (design 54 §2): the bucket allows a
|
||||||
|
// per-message age for it, and the writer gives each marker a day.
|
||||||
|
MirrorMarkerTTL = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
|
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
|
||||||
const AskedKeptFor = 30 * 24 * time.Hour
|
const AskedKeptFor = 30 * 24 * time.Hour
|
||||||
|
|
||||||
@@ -66,12 +99,14 @@ const (
|
|||||||
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
||||||
func IsControllerBucket(bucket string) bool {
|
func IsControllerBucket(bucket string) bool {
|
||||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
||||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket
|
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
|
||||||
|
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||||
@@ -168,6 +203,27 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("asserting bucket %s: %w", AskedBucket, err)
|
return fmt.Errorf("asserting bucket %s: %w", AskedBucket, err)
|
||||||
}
|
}
|
||||||
|
// **The record mirrors: no age** (design 54 §5). A key is removed when its record is, by a purge whose
|
||||||
|
// marker lives a day; a key that expired would be a record the bus forgot while the store still holds it.
|
||||||
|
for _, m := range []struct{ bucket, why string }{
|
||||||
|
{AssignmentsBucket, "which module is assigned to which node, keyed <node>.<module> (novox/hq ADR 0289, " +
|
||||||
|
"design 54): written by the controller alone from its store when an assignment changes, read through `reads`"},
|
||||||
|
{DeclaredConfigBucket, "each registered module's config declarations as its manifest holds them, keyed by " +
|
||||||
|
"the module (novox/hq ADR 0348, design 57 F3): written by the controller alone from its store when a " +
|
||||||
|
"registration changes them, read through `reads`"},
|
||||||
|
} {
|
||||||
|
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||||
|
Bucket: m.bucket,
|
||||||
|
Description: m.why,
|
||||||
|
History: 1,
|
||||||
|
MaxValueSize: MirrorValueBytes,
|
||||||
|
MaxBytes: MirrorBytes,
|
||||||
|
Storage: jetstream.FileStorage,
|
||||||
|
LimitMarkerTTL: MirrorMarkerTTL,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("asserting bucket %s: %w", m.bucket, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>"}
|
want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>",
|
||||||
|
"$KV." + AssignmentsBucket + ".>", "$KV." + DeclaredConfigBucket + ".>"}
|
||||||
for _, seat := range seatsTheControllerAsks {
|
for _, seat := range seatsTheControllerAsks {
|
||||||
if hasCancelledSet(seat) {
|
if hasCancelledSet(seat) {
|
||||||
want = append(want, "$KV."+CancelledSetName(seat)+".>")
|
want = append(want, "$KV."+CancelledSetName(seat)+".>")
|
||||||
@@ -37,6 +38,34 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A module reads a record mirror through `reads`, and never writes it** (ADR 0201 §1, design 54 §5,
|
||||||
|
// design 57 F3): granted the direct reads and watches of the bucket, and refused at composition a grant to
|
||||||
|
// write it, whose writer is the controller.
|
||||||
|
func TestAModuleReadsTheControllersMirrorsAndWritesNone(t *testing.T) {
|
||||||
|
reader := Principal{Kind: KindModule, Node: "one", Module: "mesh-config", PasswordHash: "x",
|
||||||
|
Reads: []string{"mesh-controller." + MirrorAssignments, "mesh-controller." + MirrorDeclaredConfig}}
|
||||||
|
p, err := PermissionsFor(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, bucket := range []string{AssignmentsBucket, DeclaredConfigBucket} {
|
||||||
|
for _, subject := range []string{"$JS.API.STREAM.INFO.KV_" + bucket, "$JS.API.DIRECT.GET.KV_" + bucket + ".>",
|
||||||
|
"$JS.API.CONSUMER.CREATE.KV_" + bucket + ".>"} {
|
||||||
|
if !slices.Contains(p.Publish, subject) {
|
||||||
|
t.Errorf("a module reading %s may not publish %s", bucket, subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, subject := range p.Publish {
|
||||||
|
if SubjectsOverlap(subject, "$KV."+bucket+".>") {
|
||||||
|
t.Errorf("a module reading %s may write it through %s", bucket, subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := CheckWriters(reader, []string{"$KV." + bucket + ".>"}); err == nil {
|
||||||
|
t.Errorf("a module granted a write of %s would compose", bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// **A machine's node tools may say they are there, as that machine and no other** (novox/hq to-be 45
|
// **A machine's node tools may say they are there, as that machine and no other** (novox/hq to-be 45
|
||||||
// S11), and the controller may hear the bus's advisories and ask who answers — read-only, named.
|
// S11), and the controller may hear the bus's advisories and ask who answers — read-only, named.
|
||||||
func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) {
|
func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) {
|
||||||
@@ -96,3 +125,56 @@ func TestWhatTheControllerAskedIsBounded(t *testing.T) {
|
|||||||
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A listed mirror is no undeclared bucket** (ADR 0356 §3, ADR 0201 §16): the controller's list of its mirrors
|
||||||
|
// stands for their declaration, so the report of state nothing declares never names one.
|
||||||
|
func TestAListedMirrorIsNotReportedAsUndeclared(t *testing.T) {
|
||||||
|
names := []string{"gone_state"}
|
||||||
|
for _, m := range ControllerMirrors() {
|
||||||
|
names = append(names, BucketName(ControllerSeat, m))
|
||||||
|
}
|
||||||
|
undeclared, err := RaiseBuckets(fakeBuckets{names: names}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(undeclared, []string{"gone_state"}) {
|
||||||
|
t.Fatalf("the report of undeclared state names %v", undeclared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The mirrors' list and the writers table agree** (ADR 0356 §3, to-be 45 §1): every listed mirror is a row of
|
||||||
|
// the table whose one writer is the controller, on its bucket alone; and every row that writes a bucket of the
|
||||||
|
// controller's named as a mirror is on the list.
|
||||||
|
func TestTheMirrorsListAndTheWritersTableAgree(t *testing.T) {
|
||||||
|
controller := Principal{Kind: KindController}
|
||||||
|
module := Principal{Kind: KindModule, Module: ControllerSeat, Node: "one"}
|
||||||
|
rowed := map[string]bool{}
|
||||||
|
for _, row := range WritersTable {
|
||||||
|
if !strings.HasSuffix(row.State, " mirror") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, subject := range row.Subjects {
|
||||||
|
bucket := strings.TrimSuffix(strings.TrimPrefix(subject, "$KV."), ".>")
|
||||||
|
rowed[bucket] = true
|
||||||
|
if row.Writes == nil || !row.Writes(controller, subject) || row.Writes(module, subject) {
|
||||||
|
t.Errorf("%q is not written by the controller alone", row.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listed := map[string]bool{}
|
||||||
|
for _, m := range ControllerMirrors() {
|
||||||
|
bucket := BucketName(ControllerSeat, m)
|
||||||
|
listed[bucket] = true
|
||||||
|
if !rowed[bucket] {
|
||||||
|
t.Errorf("the mirror %s is listed and no row of the writers table writes it", bucket)
|
||||||
|
}
|
||||||
|
if !IsControllerBucket(bucket) || !slices.Contains(ControllerBuckets(), bucket) {
|
||||||
|
t.Errorf("the mirror %s is listed and not asserted as the controller's own", bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for bucket := range rowed {
|
||||||
|
if !listed[bucket] {
|
||||||
|
t.Errorf("the writers table writes the mirror %s, which the list does not name", bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ func TestRaisingLogsReportsWhatNothingDeclares(t *testing.T) {
|
|||||||
// **No path of the mesh deletes or purges a log or a bucket, or recreates one** (novox/hq ADR 0297 §2,
|
// **No path of the mesh deletes or purges a log or a bucket, or recreates one** (novox/hq ADR 0297 §2,
|
||||||
// ADR 0201 §15–16): no code of this repository outside its tests calls the client's stream or bucket
|
// ADR 0201 §15–16): no code of this repository outside its tests calls the client's stream or bucket
|
||||||
// delete, or purges a stream, but for the controller lease's own stream, which purges its own history
|
// delete, or purges a stream, but for the controller lease's own stream, which purges its own history
|
||||||
// below a sequence (internal/lease). A new call is a decision, not a refactor.
|
// below a sequence (internal/lease), and the controller's record mirrors, which purge the key of a record
|
||||||
|
// the store no longer holds (internal/recordmirror; novox/hq ADR 0289, design 54 §2). A new call is a decision,
|
||||||
|
// not a refactor.
|
||||||
func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
||||||
removers := map[string]bool{"DeleteStream": true, "DeleteKeyValue": true, "PurgeStream": true,
|
removers := map[string]bool{"DeleteStream": true, "DeleteKeyValue": true, "PurgeStream": true,
|
||||||
"DeleteObjectStore": true}
|
"DeleteObjectStore": true}
|
||||||
@@ -197,7 +199,9 @@ func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
name := sel.Sel.Name
|
name := sel.Sel.Name
|
||||||
if removers[name] || (name == "Purge" && !strings.Contains(filepath.ToSlash(path), "internal/lease/")) {
|
decided := strings.Contains(filepath.ToSlash(path), "internal/lease/") ||
|
||||||
|
strings.Contains(filepath.ToSlash(path), "internal/recordmirror/")
|
||||||
|
if removers[name] || (name == "Purge" && !decided) {
|
||||||
found = append(found, path+": "+name)
|
found = append(found, path+": "+name)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
|||||||
states = append(states, link.KeyChecked)
|
states = append(states, link.KeyChecked)
|
||||||
// And a walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
// And a walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||||
states = append(states, link.KeyPlanMoved)
|
states = append(states, link.KeyPlanMoved)
|
||||||
|
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
|
||||||
|
states = append(states, link.KeyAssignmentChanged, link.KeyDeclaredConfigChanged)
|
||||||
for _, event := range states {
|
for _, event := range states {
|
||||||
if !slices.Contains(broker.ControllerStates, event) {
|
if !slices.Contains(broker.ControllerStates, event) {
|
||||||
t.Errorf("the mesh states %q and its account may not publish it", event)
|
t.Errorf("the mesh states %q and its account may not publish it", event)
|
||||||
@@ -42,6 +44,11 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
|||||||
if len(broker.ControllerStates) != len(states) {
|
if len(broker.ControllerStates) != len(states) {
|
||||||
t.Errorf("the grant permits %v, which is more than the mesh states", broker.ControllerStates)
|
t.Errorf("the grant permits %v, which is more than the mesh states", broker.ControllerStates)
|
||||||
}
|
}
|
||||||
|
// The record mirrors a module may read are the ones the bus holds (design 54 §5, design 57 F3).
|
||||||
|
if !slices.Equal(catalogue.ControllerMirrors, broker.ControllerMirrors()) {
|
||||||
|
t.Errorf("the catalogue lets a module read the controller's mirrors %v and the bus holds %v",
|
||||||
|
catalogue.ControllerMirrors, broker.ControllerMirrors())
|
||||||
|
}
|
||||||
// **And the seat says it.** A seat carries the protocol of its role (novox/hq ADR 0129), so the
|
// **And the seat says it.** A seat carries the protocol of its role (novox/hq ADR 0129), so the
|
||||||
// facts the control plane states are the seat's `emits` — which is what lets anything else declare
|
// facts the control plane states are the seat's `emits` — which is what lets anything else declare
|
||||||
// that it consumes them, and what the subject-agreement check reads to know they have an owner.
|
// that it consumes them, and what the subject-agreement check reads to know they have an owner.
|
||||||
|
|||||||
@@ -313,7 +313,10 @@ var ControllerStates = []string{"applied", "refused", "built-before",
|
|||||||
// the pull request's status, and anybody else may read.
|
// the pull request's status, and anybody else may read.
|
||||||
"checked",
|
"checked",
|
||||||
// And a walk kept in a new state (novox/hq ADR 0239): what the delivery it walks reads its steps from.
|
// And a walk kept in a new state (novox/hq ADR 0239): what the delivery it walks reads its steps from.
|
||||||
"plan-moved"}
|
"plan-moved",
|
||||||
|
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3): the assignments and
|
||||||
|
// each registered module's declared config (design 57 F3).
|
||||||
|
"assignment-changed", "declared-config-changed"}
|
||||||
|
|
||||||
// BusAdvisories are what the bus server says about the mesh's own account that the controller
|
// BusAdvisories are what the bus server says about the mesh's own account that the controller
|
||||||
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
|
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ accounts {
|
|||||||
jetstream: enabled
|
jetstream: enabled
|
||||||
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.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$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.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.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$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_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$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.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
||||||
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" }
|
||||||
} }
|
} }
|
||||||
|
|||||||
@@ -158,6 +158,15 @@ var WritersTable = []WriterRow{
|
|||||||
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
|
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
|
||||||
Others: "read, delivered again or dropped through the controller's dead-letters verb",
|
Others: "read, delivered again or dropped through the controller's dead-letters verb",
|
||||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController},
|
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController},
|
||||||
|
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
|
||||||
|
// anything else write them is refused at composition. Built so far: the assignments and each registered
|
||||||
|
// module's declared config (design 57 F3).
|
||||||
|
{State: "the assignments mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||||
|
KeptIn: "key-value " + AssignmentsBucket, Others: "read through `reads` as mesh-controller." + MirrorAssignments,
|
||||||
|
Subjects: kvOf(AssignmentsBucket), Writes: isController},
|
||||||
|
{State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||||
|
KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig,
|
||||||
|
Subjects: kvOf(DeclaredConfigBucket), Writes: isController},
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives
|
// CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ var designRows = []string{
|
|||||||
"the operator-channel's open messages",
|
"the operator-channel's open messages",
|
||||||
"the facts snapshot",
|
"the facts snapshot",
|
||||||
"a message a consumer gave up on",
|
"a message a consumer gave up on",
|
||||||
|
// The record mirrors, rows of to-be 45 §1's table by design 54 §2 (ADR 0289 §9).
|
||||||
|
"the assignments mirror",
|
||||||
|
"the declared-config mirror",
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheWritersTableIsTheDesigns(t *testing.T) {
|
func TestTheWritersTableIsTheDesigns(t *testing.T) {
|
||||||
|
|||||||
+278
-5
@@ -91,6 +91,16 @@ type Result struct {
|
|||||||
// (novox/hq issue 280).
|
// (novox/hq issue 280).
|
||||||
Source string
|
Source string
|
||||||
|
|
||||||
|
// ReuseKey is the build's reuse key (novox/hq ADR 0350 §1): its source fingerprint's parts with the builder's
|
||||||
|
// version, and ReuseParts those parts; empty, with NoKey saying why, where the source does not pin the build.
|
||||||
|
ReuseKey string
|
||||||
|
ReuseParts map[string]string
|
||||||
|
NoKey string
|
||||||
|
// Reused is the check build a walk took instead of building (ADR 0350 §2), and Why, when it built, why it did
|
||||||
|
// not take one (§3). Both empty for a build not asked to reuse.
|
||||||
|
Reused *Reused
|
||||||
|
Why string
|
||||||
|
|
||||||
// Sources are what this build was made from, as files (novox/hq ADR 0267 rule 1): per repository, the
|
// Sources are what this build was made from, as files (novox/hq ADR 0267 rule 1): per repository, the
|
||||||
// entries a changed file is tested against (SourceHolds). The module's own repository has an empty
|
// entries a changed file is tested against (SourceHolds). The module's own repository has an empty
|
||||||
// Repository. Said only for a build of a commit on the trunk, and only for a repository whose every
|
// Repository. Said only for a build of a commit on the trunk, and only for a repository whose every
|
||||||
@@ -98,6 +108,17 @@ type Result struct {
|
|||||||
// recipe and the package it compiles; for any other, nothing is said and the whole of what the build
|
// recipe and the package it compiles; for any other, nothing is said and the whole of what the build
|
||||||
// sees stays its source, as before.
|
// sees stays its source, as before.
|
||||||
Sources []BuildSource
|
Sources []BuildSource
|
||||||
|
|
||||||
|
// Predefined is the list of predefined keys at the root of the repository at the commit built (novox/hq ADR
|
||||||
|
// 0348 §7), which the manifest was judged against; nil for a repository that holds none.
|
||||||
|
Predefined *Predefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined is the list of predefined keys a build read: its text, canonical (catalogue.PredefinedList), and
|
||||||
|
// the generation of the commit built — how many commits it stands on, which orders two builds of one trunk.
|
||||||
|
type Predefined struct {
|
||||||
|
Keys []byte
|
||||||
|
Generation int
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267).
|
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267).
|
||||||
@@ -136,14 +157,44 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
// one record that it is there.
|
// one record that it is there.
|
||||||
var mirrored []string
|
var mirrored []string
|
||||||
result, err := build(ctx, run, publish, repository, path, ref, workspace, held, npmrc, forge, log,
|
result, err := build(ctx, run, publish, repository, path, ref, workspace, held, npmrc, forge, log,
|
||||||
&mirrored, seats...)
|
&mirrored, nil, seats...)
|
||||||
|
result.Mirrored = mirrored
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse is what a walk's build may take instead of building (novox/hq ADR 0350 §2): the scratch namespace's check
|
||||||
|
// builds, and the pull requests the walk answers, whose checks name a differing key.
|
||||||
|
type Reuse struct {
|
||||||
|
Scratch Scratch
|
||||||
|
Pulls []PullRef
|
||||||
|
// Named are the builds the verdicts of the walk's own pull requests name (novox/hq ADR 0350 §2): a scratch build
|
||||||
|
// is taken only when one of them names its key and every digest. The record in the store alone is never enough.
|
||||||
|
Named []NamedBuild
|
||||||
|
// keyFirst is a check's build (§1): its key is taken before anything is built, and a module with none is not
|
||||||
|
// built at all — nothing could ever take it.
|
||||||
|
keyFirst bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// errNoKey is a check's build of a module whose source does not pin it: not built (ADR 0350 §1).
|
||||||
|
var errNoKey = errors.New("it has no reuse key")
|
||||||
|
|
||||||
|
// BuildReusing is Build for the walk of a trunk commit (novox/hq ADR 0350 §2): before building, it takes the reuse
|
||||||
|
// key of the commit's tree, and where a check build holds the same key it copies that build into the module's
|
||||||
|
// namespace by digest and answers with it, building nothing. Otherwise it builds, and Result.Why says why (§3).
|
||||||
|
// Only a commit on its trunk takes a check build: anything else builds, as Build does.
|
||||||
|
func BuildReusing(ctx context.Context, run Runner, publish Publisher, reuse Reuse,
|
||||||
|
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
|
||||||
|
forge GitCredential, log Log, seats ...map[string]string) (Result, error) {
|
||||||
|
var mirrored []string
|
||||||
|
result, err := build(ctx, run, publish, repository, path, ref, workspace, held, npmrc, forge, log,
|
||||||
|
&mirrored, &reuse, seats...)
|
||||||
result.Mirrored = mirrored
|
result.Mirrored = mirrored
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func build(ctx context.Context, run Runner, publish Publisher,
|
func build(ctx context.Context, run Runner, publish Publisher,
|
||||||
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
|
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
|
||||||
forge GitCredential, log Log, mirrored *[]string, seats ...map[string]string) (Result, error) {
|
forge GitCredential, log Log, mirrored *[]string, reuse *Reuse, seats ...map[string]string) (Result, error) {
|
||||||
// The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers
|
// The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers
|
||||||
// that hand none — tests of everything but contexts — read as they did.
|
// that hand none — tests of everything but contexts — read as they did.
|
||||||
var seatBases map[string]string
|
var seatBases map[string]string
|
||||||
@@ -221,14 +272,38 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
"%s has no %s at %s, so there is nothing saying what it is: %w",
|
"%s has no %s at %s, so there is nothing saying what it is: %w",
|
||||||
repository, ManifestName, describe(path), err)
|
repository, ManifestName, describe(path), err)
|
||||||
}
|
}
|
||||||
manifest, err := catalogue.ParseManifest(raw)
|
// **The list of predefined keys at the commit built** (novox/hq ADR 0348 §7): predefined.json at the
|
||||||
|
// repository's root, read strictly; a list that is wrong fails the build, and the manifest is judged against
|
||||||
|
// the list of its own tree, never against one the mesh published from another commit.
|
||||||
|
predefined, err := catalogue.ReadPredefined(tree)
|
||||||
|
if err != nil {
|
||||||
|
say("predefined", "INVALID: %v", err)
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
manifest, err := catalogue.ParseManifestWith(raw, predefined)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("manifest", "INVALID: %v", err)
|
say("manifest", "INVALID: %v", err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
var listed *Predefined
|
||||||
|
if predefined != nil {
|
||||||
|
text, err := predefined.Canonical()
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
listed = &Predefined{Keys: text, Generation: generationOf(ctx, run, tree, commit)}
|
||||||
|
say("predefined", "%d key(s) at %s, generation %d", len(predefined), short(commit), listed.Generation)
|
||||||
|
}
|
||||||
// What is judged only where a definition is written (novox/hq ADR 0326 §3, issue 574): a build is
|
// What is judged only where a definition is written (novox/hq ADR 0326 §3, issue 574): a build is
|
||||||
// where a changed definition is first read, so it is refused here before anything is made of it.
|
// where a changed definition is first read, so it is refused here before anything is made of it.
|
||||||
if problems := catalogue.WrittenSettingsProblems(manifest); len(problems) > 0 {
|
// Judged with each template's text in its file, as written in the tree (novox/hq ADR 0349 §2): a template the
|
||||||
|
// module's directory lacks is refused here, before anything is built.
|
||||||
|
judged, err := catalogue.WithTemplatesFrom(manifest, within)
|
||||||
|
if err != nil {
|
||||||
|
say("manifest", "INVALID: %v", err)
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if problems := catalogue.WrittenSettingsProblems(judged); len(problems) > 0 {
|
||||||
err := fmt.Errorf("%s", strings.Join(problems, "; "))
|
err := fmt.Errorf("%s", strings.Join(problems, "; "))
|
||||||
say("manifest", "INVALID: %v", err)
|
say("manifest", "INVALID: %v", err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -244,6 +319,11 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if src.tree, err = gitTree(ctx, run, tree, path); err != nil {
|
if src.tree, err = gitTree(ctx, run, tree, path); err != nil {
|
||||||
src.notPinned("its tree could not be named: " + err.Error())
|
src.notPinned("its tree could not be named: " + err.Error())
|
||||||
}
|
}
|
||||||
|
// A module falling back to a predefined key was judged against the list, so a change to the list is a change
|
||||||
|
// to it: the planner rebuilds it, and its build publishes the list (ADR 0348 §7).
|
||||||
|
if predefined != nil && manifest.NamesPredefined() {
|
||||||
|
src.rootHas(catalogue.PredefinedFile)
|
||||||
|
}
|
||||||
|
|
||||||
// A build-time credential, written into the build context as .npmrc, but ONLY for a module that
|
// A build-time credential, written into the build context as .npmrc, but ONLY for a module that
|
||||||
// asks for it: a `package` artifact (which publishes), or an image whose Dockerfile COPYs .npmrc.
|
// asks for it: a `package` artifact (which publishes), or an image whose Dockerfile COPYs .npmrc.
|
||||||
@@ -284,6 +364,9 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
var built []catalogue.Built
|
var built []catalogue.Built
|
||||||
// stoodOn is every base the build was handed, as resolved — the edges the catalogue derives.
|
// stoodOn is every base the build was handed, as resolved — the edges the catalogue derives.
|
||||||
var stoodOn []string
|
var stoodOn []string
|
||||||
|
// reused is the check build a walk took, and builtWhy why it built instead (novox/hq ADR 0350).
|
||||||
|
var reused *Reused
|
||||||
|
builtWhy := ""
|
||||||
if manifest.Build != nil {
|
if manifest.Build != nil {
|
||||||
// What this module said it stands on, answered with what this mesh actually holds. Done
|
// What this module said it stands on, answered with what this mesh actually holds. Done
|
||||||
// before anything is built, so a missing base is refused in front of the person who can
|
// before anything is built, so a missing base is refused in front of the person who can
|
||||||
@@ -327,7 +410,35 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
// Ordered, so two builds of one commit do the same work in the same sequence and their
|
// Ordered, so two builds of one commit do the same work in the same sequence and their
|
||||||
// logs can be compared.
|
// logs can be compared.
|
||||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
||||||
|
// **A walk takes the check's build of the same key** (novox/hq ADR 0350 §2): only a commit on its trunk, and
|
||||||
|
// only when asked — a check, a dry run, `build --ref` and `rebuild` never are. Its key is taken by the same
|
||||||
|
// steps a build takes, stopped before anything is built; what it takes is copied by digest.
|
||||||
|
if reuse != nil && reuse.keyFirst {
|
||||||
|
_, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args,
|
||||||
|
held, npmrcPath, npmrc, seatBases, src, say)
|
||||||
|
if err == nil && key.Key == "" {
|
||||||
|
err = fmt.Errorf("%w: %s", errNoKey, key.Why)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
say("reuse", "not built for the merge: %v", err)
|
||||||
|
return Result{Commit: commit, NoKey: err.Error()}, err
|
||||||
|
}
|
||||||
|
} else if reuse != nil && reuse.Scratch != nil {
|
||||||
|
taken, keyed, why := takeACheckBuild(ctx, run, publish, *reuse, manifest, within, workspace, commit,
|
||||||
|
credentials, artifacts, args, held, npmrcPath, npmrc, seatBases, src, onTrunk, say)
|
||||||
|
if taken != nil {
|
||||||
|
built = taken.Made
|
||||||
|
src = keyed
|
||||||
|
reused = taken
|
||||||
|
} else {
|
||||||
|
builtWhy = why
|
||||||
|
say("reuse", "built: %s", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, a := range artifacts {
|
for _, a := range artifacts {
|
||||||
|
if reused != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
||||||
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, src, say)
|
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, src, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -339,6 +450,11 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each template's text, read back from the archive the build made or took (novox/hq ADR 0349 §2).
|
||||||
|
if built, err = withTemplateTexts(manifest, within, built); err != nil {
|
||||||
|
say("templates", "FAILED: %v", err)
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
resolved, err := manifest.Resolve(built)
|
resolved, err := manifest.Resolve(built)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("resolve", "FAILED: %v", err)
|
say("resolve", "FAILED: %v", err)
|
||||||
@@ -363,9 +479,123 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
say("source", "%d path(s) of %s", len(s.Paths), where)
|
say("source", "%d path(s) of %s", len(s.Paths), where)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The reuse key (novox/hq ADR 0350 §1): what a check keeps its build under, and what a walk takes one by.
|
||||||
|
key := keyOf(src, manifest, Version)
|
||||||
return Result{Manifest: resolved, Commit: commit, Built: built,
|
return Result{Manifest: resolved, Commit: commit, Built: built,
|
||||||
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
|
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
|
||||||
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources}, nil
|
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources,
|
||||||
|
ReuseKey: key.Key, ReuseParts: key.Parts, NoKey: key.Why, Reused: reused, Why: builtWhy,
|
||||||
|
Predefined: listed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generationOf is how many commits the commit built stands on, itself included: of two commits on one trunk the
|
||||||
|
// later has more, so the controller keeps the list of the newer one whatever order it hears them in (ADR 0348
|
||||||
|
// §7). 0 when git could not say.
|
||||||
|
func generationOf(ctx context.Context, run Runner, tree, commit string) int {
|
||||||
|
out, err := run(ctx, tree, "git", "rev-list", "--count", commit)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(out))
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeACheckBuild is the walk's look for a check build of its key (novox/hq ADR 0350 §2, §3): the key taken by
|
||||||
|
// the steps a build takes, each artifact stopped before it is built; the build kept under it copied by digest into
|
||||||
|
// the module's namespace. Answers what it took and the source inputs its key was taken from, or why it builds. A
|
||||||
|
// copy that fails is said, and the walk builds (§3): it never fails the walk on its own.
|
||||||
|
func takeACheckBuild(ctx context.Context, run Runner, publish Publisher, reuse Reuse, manifest catalogue.Manifest,
|
||||||
|
within, workspace, commit, credentials string, artifacts []catalogue.Artifact, args []string,
|
||||||
|
held map[string]string, npmrcPath string, npmrc Npmrc, seats map[string]string, src *sourceInputs, onTrunk bool,
|
||||||
|
say func(step, format string, args ...any)) (*Reused, *sourceInputs, string) {
|
||||||
|
if !onTrunk {
|
||||||
|
return nil, nil, "the commit is not on its trunk, and only a trunk commit's walk takes a check's build"
|
||||||
|
}
|
||||||
|
keyed, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args,
|
||||||
|
held, npmrcPath, npmrc, seats, src, say)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err.Error()
|
||||||
|
}
|
||||||
|
if key.Key == "" {
|
||||||
|
return nil, nil, "it has no reuse key: " + key.Why
|
||||||
|
}
|
||||||
|
found, ok, err := reuse.Scratch.Find(ctx, manifest.Module, key.Key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "the scratch namespace could not be read: " + err.Error()
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, notKeptWhy(ctx, reuse, manifest.Module, key)
|
||||||
|
}
|
||||||
|
// **Only a build a verdict names** (ADR 0350 §2): the store takes writes from anything on the network, so a
|
||||||
|
// record is believed only when a check verdict of the walk's own pull requests names its key and its digests.
|
||||||
|
if why := unnamed(reuse.Named, manifest.Module, key.Key, found); why != "" {
|
||||||
|
return nil, nil, why
|
||||||
|
}
|
||||||
|
taken := &Reused{From: found}
|
||||||
|
for _, made := range found.Made {
|
||||||
|
copied, err := reuse.Scratch.Take(ctx, manifest.Module, made)
|
||||||
|
if err != nil {
|
||||||
|
why := fmt.Sprintf("copying %s's %s from %s failed (%v)", manifest.Module, made.Name, found.Said(), err)
|
||||||
|
say("reuse", "%s; built instead", why)
|
||||||
|
return nil, nil, why
|
||||||
|
}
|
||||||
|
taken.Made = append(taken.Made, copied)
|
||||||
|
}
|
||||||
|
// Every artifact the manifest declares, or none: a check build missing one is not this build.
|
||||||
|
if len(taken.Made) != len(artifacts) {
|
||||||
|
return nil, nil, fmt.Sprintf("%s holds %d artifact(s) of %s, which declares %d", found.Said(), len(taken.Made),
|
||||||
|
manifest.Module, len(artifacts))
|
||||||
|
}
|
||||||
|
say("reuse", "%s", taken.Words())
|
||||||
|
return taken, keyed, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyFor takes a build's reuse key by the steps a build takes, each artifact stopped before anything is built or
|
||||||
|
// published (ADR 0350 §2): its own copy of the source inputs, so the build's are untouched.
|
||||||
|
func keyFor(ctx context.Context, run Runner, publish Publisher, manifest catalogue.Manifest,
|
||||||
|
within, workspace, commit, credentials string, artifacts []catalogue.Artifact, args []string,
|
||||||
|
held map[string]string, npmrcPath string, npmrc Npmrc, seats map[string]string, src *sourceInputs,
|
||||||
|
say func(step, format string, args ...any)) (*sourceInputs, reuseKey, error) {
|
||||||
|
keyed := src.copy()
|
||||||
|
keyed.onlyKey = true
|
||||||
|
for _, a := range artifacts {
|
||||||
|
if _, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held,
|
||||||
|
npmrcPath, npmrc, seats, keyed, say); err != nil {
|
||||||
|
return nil, reuseKey{}, fmt.Errorf("its reuse key could not be taken: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyed.onlyKey = false
|
||||||
|
return keyed, keyOf(keyed, manifest, Version), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notKeptWhy is why no check build has the key (§3): no check of the walk's pull requests built the module, the
|
||||||
|
// key differs — naming the part — or the build it made is gone.
|
||||||
|
func notKeptWhy(ctx context.Context, reuse Reuse, module string, key reuseKey) string {
|
||||||
|
if len(reuse.Pulls) == 0 {
|
||||||
|
return "no check built it with this key"
|
||||||
|
}
|
||||||
|
builds, err := reuse.Scratch.OfPulls(ctx, reuse.Pulls)
|
||||||
|
if err != nil {
|
||||||
|
return "no check build has this key, and which checks built it could not be read: " + err.Error()
|
||||||
|
}
|
||||||
|
for _, b := range builds {
|
||||||
|
if b.Module != module {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Key == key.Key {
|
||||||
|
return b.Said() + " built it with this key, and it was removed"
|
||||||
|
}
|
||||||
|
return "the key differs from " + b.Said() + "'s in " + strings.Join(differing(b.Parts, key.Parts), ", ")
|
||||||
|
}
|
||||||
|
var pulls []string
|
||||||
|
for _, p := range reuse.Pulls {
|
||||||
|
pulls = append(pulls, p.String())
|
||||||
|
}
|
||||||
|
return "no check of " + strings.Join(pulls, ", ") + " built it (a module its check did not move, or a check " +
|
||||||
|
"that did not finish)"
|
||||||
}
|
}
|
||||||
|
|
||||||
// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`.
|
// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`.
|
||||||
@@ -754,6 +984,10 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
|
|
||||||
switch a.Kind {
|
switch a.Kind {
|
||||||
case catalogue.ArtifactUpstream:
|
case catalogue.ArtifactUpstream:
|
||||||
|
// Its key is the image it copies, read from the manifest (keyOf): nothing to take here.
|
||||||
|
if src.keyOnly() {
|
||||||
|
return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil
|
||||||
|
}
|
||||||
// Mirrored, not built: copied under a name of the mesh's own, so what a machine fetches is
|
// Mirrored, not built: copied under a name of the mesh's own, so what a machine fetches is
|
||||||
// pinned by a digest this registry assigned rather than by a tag somebody else can move.
|
// pinned by a digest this registry assigned rather than by a tag somebody else can move.
|
||||||
//
|
//
|
||||||
@@ -882,6 +1116,10 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
} else {
|
} else {
|
||||||
src.ownWhole()
|
src.ownWhole()
|
||||||
}
|
}
|
||||||
|
// Taken for a reuse key (novox/hq ADR 0350 §2): everything its fingerprint reads is read; nothing is built.
|
||||||
|
if src.keyOnly() {
|
||||||
|
return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil
|
||||||
|
}
|
||||||
invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...)
|
invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...)
|
||||||
if a.Target != "" {
|
if a.Target != "" {
|
||||||
invocation = append(invocation, "--target", a.Target)
|
invocation = append(invocation, "--target", a.Target)
|
||||||
@@ -899,6 +1137,16 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err)
|
||||||
}
|
}
|
||||||
say("image", "built, publishing")
|
say("image", "built, publishing")
|
||||||
|
// Its bases linked into its repository first, so the push needs none of their blobs from this
|
||||||
|
// machine's store, which may not hold them (novox/hq issue 694). Said and not fatal: a push
|
||||||
|
// from a store that holds them works without, and one that fails says which blob it lacked.
|
||||||
|
if lender, can := publish.(BaseLender); can {
|
||||||
|
if n, err := lender.LendBases(ctx, module+"/"+a.Name, buildArgValues(args)); err != nil {
|
||||||
|
say("image", "its bases could not all be linked into %s/%s before the push: %v", module, a.Name, err)
|
||||||
|
} else if n > 0 {
|
||||||
|
say("image", "%d blob(s) of its bases linked into %s/%s before the push", n, module, a.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
reference, err := publish.PublishImage(ctx, local, module+"/"+a.Name)
|
reference, err := publish.PublishImage(ctx, local, module+"/"+a.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, err
|
return catalogue.Built{}, err
|
||||||
@@ -949,6 +1197,9 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
src.notPinned(a.Name + " resolves packages of its own at build time")
|
src.notPinned(a.Name + " resolves packages of its own at build time")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if src.keyOnly() {
|
||||||
|
return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil
|
||||||
|
}
|
||||||
// The module's own packages first, where the compiler and the bundler resolve them from
|
// The module's own packages first, where the compiler and the bundler resolve them from
|
||||||
// (dependencies.go); nothing at all for a module whose package.json names only the SDK.
|
// (dependencies.go); nothing at all for a module whose package.json names only the SDK.
|
||||||
if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil {
|
if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil {
|
||||||
@@ -992,6 +1243,9 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
say("package", "building and publishing %s (%s)", a.Name, a.Language)
|
say("package", "building and publishing %s (%s)", a.Name, a.Language)
|
||||||
src.ownWhole()
|
src.ownWhole()
|
||||||
src.notPinned(a.Name + " is a package, built from what the registry holds when it is built")
|
src.notPinned(a.Name + " is a package, built from what the registry holds when it is built")
|
||||||
|
if src.keyOnly() {
|
||||||
|
return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil
|
||||||
|
}
|
||||||
reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say)
|
reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: publishing %s failed: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: publishing %s failed: %w", module, a.Name, err)
|
||||||
@@ -1000,6 +1254,12 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
||||||
|
|
||||||
case catalogue.ArtifactArchive:
|
case catalogue.ArtifactArchive:
|
||||||
|
if src.keyOnly() {
|
||||||
|
if a.URL == "" {
|
||||||
|
src.ownHas(cleanEntry(a.From) + "/**")
|
||||||
|
}
|
||||||
|
return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil
|
||||||
|
}
|
||||||
var body []byte
|
var body []byte
|
||||||
var err error
|
var err error
|
||||||
if a.URL != "" {
|
if a.URL != "" {
|
||||||
@@ -1238,6 +1498,19 @@ func standingOn(ctx context.Context, manifest catalogue.Manifest, held map[strin
|
|||||||
return args, resolved, nil
|
return args, resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildArgValues is what each --build-arg of a build was handed: the bases, as this mesh holds them.
|
||||||
|
func buildArgValues(args []string) []string {
|
||||||
|
var values []string
|
||||||
|
for i := 0; i+1 < len(args); i += 2 {
|
||||||
|
if args[i] == "--build-arg" {
|
||||||
|
if _, value, has := strings.Cut(args[i+1], "="); has {
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
// compile runs a module's own code through its toolchain, and says where the result is.
|
// compile runs a module's own code through its toolchain, and says where the result is.
|
||||||
//
|
//
|
||||||
// **In the module's own directory, under the path the toolchain expects.** A module is compiled
|
// **In the module's own directory, under the path the toolchain expects.** A module is compiled
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ func (r *recorded) run(_ context.Context, dir, name string, args ...string) (str
|
|||||||
return r.tree + "\n", nil
|
return r.tree + "\n", nil
|
||||||
case name == "git" && len(args) > 0 && args[0] == "rev-parse":
|
case name == "git" && len(args) > 0 && args[0] == "rev-parse":
|
||||||
return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil
|
return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil
|
||||||
|
case name == "git" && len(args) > 1 && args[0] == "rev-list" && args[1] == "--count":
|
||||||
|
return "42\n", nil
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,28 @@ type CheckSpec struct {
|
|||||||
// and composed with it by the gate as one future state. The repository's own check is not run for a
|
// and composed with it by the gate as one future state. The repository's own check is not run for a
|
||||||
// group: each member's pull request runs its own.
|
// group: each member's pull request runs its own.
|
||||||
Group []GroupHead
|
Group []GroupHead
|
||||||
|
// Builds are the modules the delivery plan moves, in the walk's order, and BuildWith what they are built with
|
||||||
|
// (novox/hq ADR 0350 §1): built from the merge the check judged into the scratch namespace once it has judged,
|
||||||
|
// for the walk to take. None for a group's composed check, whose members' own checks build.
|
||||||
|
Builds []CheckBuildSpec
|
||||||
|
BuildWith *CheckBuilding
|
||||||
|
// Tests and StoreOnly narrow what the repository's own script runs, for a branch tried before its pull
|
||||||
|
// request (novox/hq issue 686): handed to the script as EnvTests and EnvStoreOnly, which it may read.
|
||||||
|
Tests string
|
||||||
|
StoreOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// selection is what a check hands the repository's script to narrow what it runs (novox/hq issue 686): nothing
|
||||||
|
// for a pull request's check, which always runs whole.
|
||||||
|
func selection(spec CheckSpec) []string {
|
||||||
|
var env []string
|
||||||
|
if spec.Tests != "" {
|
||||||
|
env = append(env, EnvTests+"="+spec.Tests)
|
||||||
|
}
|
||||||
|
if spec.StoreOnly {
|
||||||
|
env = append(env, EnvStoreOnly+"=1")
|
||||||
|
}
|
||||||
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupHead is one other head of a delivery group's composed check.
|
// GroupHead is one other head of a delivery group's composed check.
|
||||||
@@ -155,6 +177,8 @@ type CheckVerdict struct {
|
|||||||
// Members are a delivery group's other heads as the composed check judged them, each with the trunk
|
// Members are a delivery group's other heads as the composed check judged them, each with the trunk
|
||||||
// commit it was merged into (ADR 0323 §6).
|
// commit it was merged into (ADR 0323 §6).
|
||||||
Members []JudgedHead
|
Members []JudgedHead
|
||||||
|
// Built are the builds it made for its merge (novox/hq ADR 0350 §1), each with its key, or why not.
|
||||||
|
Built []CheckBuilt
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer is one layer of a check, judged.
|
// Layer is one layer of a check, judged.
|
||||||
@@ -186,6 +210,11 @@ 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"
|
||||||
|
// 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
|
||||||
|
// check. A script that reads neither runs whole.
|
||||||
|
EnvTests = "MESH_CHECK_TESTS"
|
||||||
|
EnvStoreOnly = "MESH_CHECK_STORE_ONLY"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckTimeout bounds one check; a check that runs past it is an error, not a pass.
|
// CheckTimeout bounds one check; a check that runs past it is an error, not a pass.
|
||||||
@@ -508,6 +537,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","),
|
EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","),
|
||||||
EnvGroup + "=" + groupFile,
|
EnvGroup + "=" + groupFile,
|
||||||
"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, selection(spec)...)
|
||||||
|
|
||||||
v := CheckVerdict{}
|
v := CheckVerdict{}
|
||||||
modules := append(append([]string{}, spec.Modules...), prefixed("new:", spec.New)...)
|
modules := append(append([]string{}, spec.Modules...), prefixed("new:", spec.New)...)
|
||||||
@@ -572,9 +602,38 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
v.Report, v.Took = redact.redact(withWhatFailed(out.String(), v.Repo)), time.Since(began)
|
v.Report, v.Took = redact.redact(withWhatFailed(out.String(), v.Repo)), time.Since(began)
|
||||||
say("check", "gate %s — %s; repository %s — %s (%s)", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary,
|
say("check", "gate %s — %s; repository %s — %s (%s)", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary,
|
||||||
strings.ToUpper(v.Repo.Verdict), v.Repo.Summary, v.Took.Round(time.Second))
|
strings.ToUpper(v.Repo.Verdict), v.Repo.Summary, v.Took.Round(time.Second))
|
||||||
|
|
||||||
|
// **What the merge would move, built once, here** (novox/hq ADR 0350 §1): after the judging, from the merge it
|
||||||
|
// judged, into the scratch namespace — so the walk of the merge commit takes it rather than building again.
|
||||||
|
if len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0 {
|
||||||
|
if why := skipsTheBuilds(v); why != "" {
|
||||||
|
say("check", "%s", why)
|
||||||
|
} else {
|
||||||
|
began := time.Now()
|
||||||
|
v.Built = buildForTheMerge(ctx, run, spec, *spec.BuildWith, workspace, tree, judged.Merge, judged, forge,
|
||||||
|
func(step, message string) {
|
||||||
|
if log != nil {
|
||||||
|
log(step, redact.redact(message))
|
||||||
|
}
|
||||||
|
}, say)
|
||||||
|
say("check", "built for the merge: %d of %d module(s) (%s)", builtCount(v.Built), len(v.Built),
|
||||||
|
time.Since(began).Round(time.Second))
|
||||||
|
}
|
||||||
|
}
|
||||||
return named(v), nil
|
return named(v), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// builtCount is how many of a check's builds for its merge were built.
|
||||||
|
func builtCount(built []CheckBuilt) int {
|
||||||
|
n := 0
|
||||||
|
for _, b := range built {
|
||||||
|
if b.NotBuilt == "" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// ownCheck runs the repository's own check, part by part, each in its toolchain's own container: the
|
// ownCheck runs the repository's own check, part by part, each in its toolchain's own container: the
|
||||||
// layer's verdict. Nil when the check was ended from outside (not past its bound), which the caller
|
// layer's verdict. Nil when the check was ended from outside (not past its bound), which the caller
|
||||||
// answers as the context's error.
|
// answers as the context's error.
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The builds a merge check makes for its merge (novox/hq ADR 0350 §1): every artifact of every module the delivery
|
||||||
|
// plan moves, built from the merged tree the check judged into the scratch namespace, kept under its reuse key, so
|
||||||
|
// the walk of the merge commit takes it instead of building again. Built after the check has judged, and only when
|
||||||
|
// it did not fail: a change that fails is not merged, and nothing it built would be taken.
|
||||||
|
|
||||||
|
// CheckBuildSpec is one module a check builds for its merge, in the walk's order (Tier).
|
||||||
|
type CheckBuildSpec struct {
|
||||||
|
Module string
|
||||||
|
// Repository, Path and Ref are where it is built from; Merged says from the merge the check made instead — the
|
||||||
|
// module is built from the repository checked, at the branch merged into.
|
||||||
|
Repository string
|
||||||
|
Path string
|
||||||
|
Ref string
|
||||||
|
Merged bool
|
||||||
|
Tier int
|
||||||
|
// Skip says why it cannot be built as the walk would build it: not built, and nothing after it.
|
||||||
|
Skip string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckBuilding is what a check builds with: where it publishes and keeps its builds, and what a build is handed.
|
||||||
|
type CheckBuilding struct {
|
||||||
|
Publish Publisher
|
||||||
|
Scratch Scratch
|
||||||
|
Held map[string]string
|
||||||
|
Npmrc Npmrc
|
||||||
|
Seats map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckBuilt is one module a check built for its merge, as its verdict lists it (§1): its key and its artifacts, or
|
||||||
|
// why it was not built.
|
||||||
|
type CheckBuilt struct {
|
||||||
|
Module string
|
||||||
|
Key string
|
||||||
|
Made []catalogue.Built
|
||||||
|
// NotBuilt says why it was not: no key, a build that failed, or a module after one of those.
|
||||||
|
NotBuilt string
|
||||||
|
Took time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipsTheBuilds is why a check builds nothing for its merge; empty when it builds.
|
||||||
|
func skipsTheBuilds(v CheckVerdict) string {
|
||||||
|
for _, l := range []*Layer{v.Gate, v.Repo} {
|
||||||
|
if l != nil && (l.Verdict == "fail" || l.Verdict == "error") {
|
||||||
|
return "the check did not pass, so nothing is built for its merge"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildForTheMerge builds each module the plan moves, tier by tier, into the scratch namespace, and keeps what it
|
||||||
|
// built under its keys. A module standing after one not built is not built either: what it stands on in the walk
|
||||||
|
// is not what it would stand on here. tree is the check's clone and merge the merge commit it made there.
|
||||||
|
func buildForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with CheckBuilding, workspace, tree, merge string,
|
||||||
|
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) []CheckBuilt {
|
||||||
|
if len(spec.Builds) == 0 || with.Publish == nil || with.Scratch == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ref := merge
|
||||||
|
if ref == "" {
|
||||||
|
ref = spec.Ref
|
||||||
|
}
|
||||||
|
held := map[string]string{}
|
||||||
|
for k, v := range with.Held {
|
||||||
|
held[k] = v
|
||||||
|
}
|
||||||
|
var out []CheckBuilt
|
||||||
|
var kept []CheckBuild
|
||||||
|
stopAt, stopWhy := -1, ""
|
||||||
|
for _, b := range spec.Builds {
|
||||||
|
began := time.Now()
|
||||||
|
if stopAt >= 0 && b.Tier > stopAt {
|
||||||
|
out = append(out, CheckBuilt{Module: b.Module, NotBuilt: stopWhy})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Skip != "" {
|
||||||
|
say("build", "%s not built for the merge: %s", b.Module, b.Skip)
|
||||||
|
out = append(out, CheckBuilt{Module: b.Module, NotBuilt: b.Skip})
|
||||||
|
if stopAt < 0 || b.Tier < stopAt {
|
||||||
|
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
repository, at := b.Repository, b.Ref
|
||||||
|
if b.Merged {
|
||||||
|
repository, at = tree, ref
|
||||||
|
}
|
||||||
|
say("build", "for the merge: %s (tier %d)", b.Module, b.Tier)
|
||||||
|
var mirrored []string
|
||||||
|
result, err := build(ctx, run, scratchPublisher{to: with.Publish}, repository, b.Path, at,
|
||||||
|
filepath.Join(workspace, "for-the-merge"), held, with.Npmrc, forge, log, &mirrored, &Reuse{keyFirst: true},
|
||||||
|
with.Seats)
|
||||||
|
built := CheckBuilt{Module: b.Module, Key: result.ReuseKey, Took: time.Since(began)}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errNoKey):
|
||||||
|
built.NotBuilt = err.Error()
|
||||||
|
case err != nil:
|
||||||
|
built.NotBuilt = "its build failed: " + err.Error()
|
||||||
|
case result.ReuseKey == "":
|
||||||
|
built.NotBuilt = "it has no reuse key: " + result.NoKey
|
||||||
|
}
|
||||||
|
if built.NotBuilt != "" {
|
||||||
|
say("build", "%s not built for the merge: %s", b.Module, built.NotBuilt)
|
||||||
|
out = append(out, built)
|
||||||
|
if stopAt < 0 || b.Tier < stopAt {
|
||||||
|
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
built.Made = result.Built
|
||||||
|
for _, m := range result.Built {
|
||||||
|
held[b.Module+"/"+m.Name] = m.Reference
|
||||||
|
}
|
||||||
|
say("build", "%s built for the merge under %s (%s)", b.Module, twelve(result.ReuseKey[3:]), built.Took.Round(time.Second))
|
||||||
|
out = append(out, built)
|
||||||
|
if len(result.Built) > 0 {
|
||||||
|
kept = append(kept, CheckBuild{Key: result.ReuseKey, Module: b.Module, Parts: result.ReuseParts,
|
||||||
|
Made: result.Built, Pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number},
|
||||||
|
Head: judged.Head, Trunk: judged.Trunk, Check: spec.ID, At: time.Now().UTC()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(filepath.Join(workspace, "for-the-merge"))
|
||||||
|
if len(kept) > 0 {
|
||||||
|
if err := with.Scratch.Keep(ctx, PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, spec.ID,
|
||||||
|
time.Now().UTC(), kept); err != nil {
|
||||||
|
// Built and not findable: the walk builds, and says no check built it. Said, never the check's verdict.
|
||||||
|
say("build", "the builds for the merge could not be kept, and the walk will build: %v", err)
|
||||||
|
if err := with.Scratch.Discard(ctx, kept); err != nil {
|
||||||
|
say("build", "and what they made could not all be let go of: %v", err)
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
if out[i].NotBuilt == "" {
|
||||||
|
out[i].NotBuilt = fmt.Sprintf("built, and could not be kept: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -590,3 +590,15 @@ func TestARepositoryInTwoLanguagesIsCheckedInBoth(t *testing.T) {
|
|||||||
t.Errorf("a part in a toolchain the mesh does not hold answered %+v — never a pass", layer)
|
t.Errorf("a part in a toolchain the mesh does not hold answered %+v — never a pass", layer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A branch tried before its pull request may narrow what the repository's script runs (novox/hq issue 686); a
|
||||||
|
// pull request's check is handed nothing, and runs whole.
|
||||||
|
func TestOnlyATrysSelectionReachesTheScript(t *testing.T) {
|
||||||
|
if env := selection(CheckSpec{Number: 7}); len(env) != 0 {
|
||||||
|
t.Fatalf("a pull request's check was handed a selection: %v", env)
|
||||||
|
}
|
||||||
|
env := selection(CheckSpec{Tests: "^TestX$", StoreOnly: true})
|
||||||
|
if len(env) != 2 || env[0] != "MESH_CHECK_TESTS=^TestX$" || env[1] != "MESH_CHECK_STORE_ONLY=1" {
|
||||||
|
t.Fatalf("a try's selection: %v", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **A built image's bases are linked into its repository before it is pushed** (novox/hq issue 694).
|
||||||
|
//
|
||||||
|
// A push reads every blob of the image out of this machine's store, unless the repository it goes to
|
||||||
|
// already holds that blob: the runtime asks the registry first, blob by blob, and reads locally only
|
||||||
|
// what the registry lacks. A runtime keeping its images in containerd's store — the default for a
|
||||||
|
// fresh install from docker 29 on — does not always hold the compressed layers of the base an image
|
||||||
|
// was built FROM: a layer whose unpacked form it already had is never fetched. The push of such an
|
||||||
|
// image then fails on a layer this machine never had ("not all of them are available locally"), while
|
||||||
|
// the very bytes sit in this registry, under the repository the base was mirrored into.
|
||||||
|
//
|
||||||
|
// So the base's blobs are mounted into the image's repository first. A mount moves no bytes, and once
|
||||||
|
// the repository holds a blob the push asks this machine for nothing of it — whichever store the
|
||||||
|
// runtime keeps. A runtime that holds every layer (the classic store) is not changed by this: its push
|
||||||
|
// finds the same blobs already there.
|
||||||
|
|
||||||
|
// BaseLender links what a build's bases hold into the repository its image is about to be pushed to.
|
||||||
|
type BaseLender interface {
|
||||||
|
// LendBases mounts every blob of each base this registry holds — for the platform this machine
|
||||||
|
// builds — into `repository`, and answers how many it mounted.
|
||||||
|
LendBases(ctx context.Context, repository string, bases []string) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformed is an index's entry: a manifest and the platform it is for.
|
||||||
|
type platformed struct {
|
||||||
|
descriptor
|
||||||
|
Platform *struct {
|
||||||
|
OS string `json:"os"`
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
} `json:"platform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LendBases mounts each base's blobs into the repository. A base this registry does not hold under its
|
||||||
|
// repository — named by tag, or held somewhere else — lends nothing and is no error: the push reads it
|
||||||
|
// from this machine's store as before.
|
||||||
|
func (r Registry) LendBases(ctx context.Context, repository string, bases []string) (int, error) {
|
||||||
|
mounted := 0
|
||||||
|
for _, base := range bases {
|
||||||
|
name, digest, pinned := strings.Cut(base, "@")
|
||||||
|
if !pinned || !strings.HasPrefix(digest, "sha256:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
where, err := parseReference(name + "@" + digest)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
from := where.repository
|
||||||
|
if from == repository {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blobs, held, err := r.blobsOf(ctx, from, digest)
|
||||||
|
if err != nil {
|
||||||
|
return mounted, fmt.Errorf("reading %s: %w", base, err)
|
||||||
|
}
|
||||||
|
if !held {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, blob := range blobs {
|
||||||
|
did, err := r.mount(ctx, repository, from, blob)
|
||||||
|
if err != nil {
|
||||||
|
return mounted, fmt.Errorf("linking %s of %s into %s: %w", blob, base, repository, err)
|
||||||
|
}
|
||||||
|
if did {
|
||||||
|
mounted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mounted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// blobsOf is the config and layers of the manifest under repository@digest — for an index, of each
|
||||||
|
// manifest it names for this machine's platform. False when the registry holds no such manifest there.
|
||||||
|
func (r Registry) blobsOf(ctx context.Context, repository, digest string) ([]string, bool, error) {
|
||||||
|
body, held, err := r.manifest(ctx, repository, digest)
|
||||||
|
if err != nil || !held {
|
||||||
|
return nil, held, err
|
||||||
|
}
|
||||||
|
var document struct {
|
||||||
|
Manifests []platformed `json:"manifests"`
|
||||||
|
Config *descriptor `json:"config"`
|
||||||
|
Layers []descriptor `json:"layers"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &document); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("%s@%s is not a manifest: %w", repository, digest, err)
|
||||||
|
}
|
||||||
|
var blobs []string
|
||||||
|
if document.Config != nil {
|
||||||
|
blobs = append(blobs, document.Config.Digest)
|
||||||
|
}
|
||||||
|
for _, l := range document.Layers {
|
||||||
|
blobs = append(blobs, l.Digest)
|
||||||
|
}
|
||||||
|
for _, m := range document.Manifests {
|
||||||
|
if m.Platform == nil || m.Platform.OS != runtime.GOOS || m.Platform.Architecture != runtime.GOARCH {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
more, held, err := r.blobsOf(ctx, repository, m.Digest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if held {
|
||||||
|
blobs = append(blobs, more...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blobs, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifest reads one manifest document from this registry; false when it is not there.
|
||||||
|
func (r Registry) manifest(ctx context.Context, repository, digest string) ([]byte, bool, error) {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||||
|
"http://"+r.Address+"/v2/"+repository+"/manifests/"+digest, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", manifestAccept)
|
||||||
|
response, err := r.client().Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("cannot reach the registry at %s: %w", r.Address, err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return nil, false, fmt.Errorf("%s/%s@%s: %s", r.Address, repository, digest, response.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
|
||||||
|
return body, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// mount links one blob from one repository into another; false when the repository already held it.
|
||||||
|
func (r Registry) mount(ctx context.Context, repository, from, digest string) (bool, error) {
|
||||||
|
base := "http://" + r.Address + "/v2/" + repository
|
||||||
|
if there, err := r.has(ctx, base+"/blobs/"+digest); err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if there {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
start, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
base+"/blobs/uploads/?mount="+digest+"&from="+from, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
begun, err := r.client().Do(start)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("cannot reach the registry at %s: %w", r.Address, err)
|
||||||
|
}
|
||||||
|
begun.Body.Close()
|
||||||
|
if begun.StatusCode == http.StatusCreated {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if begun.StatusCode == http.StatusAccepted {
|
||||||
|
// Not mounted, and an upload begun in its place: let go of it, so nothing is left half-open.
|
||||||
|
if location := begun.Header.Get("Location"); location != "" {
|
||||||
|
if strings.HasPrefix(location, "/") {
|
||||||
|
location = "http://" + r.Address + location
|
||||||
|
}
|
||||||
|
if cancel, err := http.NewRequestWithContext(ctx, http.MethodDelete, location, nil); err == nil {
|
||||||
|
if done, err := r.client().Do(cancel); err == nil {
|
||||||
|
done.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("%s did not mount it from %s", r.Address, from)
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("%s answered %s when asked to mount it from %s", r.Address, begun.Status, from)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A built image's bases are linked into its repository before it is pushed (novox/hq issue 694): a
|
||||||
|
// runtime keeping containerd's image store may not hold a base layer it never had to fetch, and its push
|
||||||
|
// asks this machine only for what the repository lacks.
|
||||||
|
|
||||||
|
// aBaseForThisMachine puts an index into the registry under `repository`: one manifest for this
|
||||||
|
// machine's platform, one for another, and answers the index's digest and each manifest's blobs.
|
||||||
|
func aBaseForThisMachine(t *testing.T, dst *aRegistryOfRepositories, repository string) (string, []string, []string) {
|
||||||
|
t.Helper()
|
||||||
|
put := func(arch string) (string, []string) {
|
||||||
|
config := []byte(`{"architecture":"` + arch + `"}`)
|
||||||
|
layer := []byte(arch + " layer bytes")
|
||||||
|
for _, b := range [][]byte{config, layer} {
|
||||||
|
dst.blobs[digestOf(b)] = b
|
||||||
|
dst.link(repository, digestOf(b))
|
||||||
|
}
|
||||||
|
m, _ := json.Marshal(map[string]any{
|
||||||
|
"schemaVersion": 2, "mediaType": mediaManifestOCI,
|
||||||
|
"config": map[string]any{"mediaType": "application/vnd.oci.image.config.v1+json", "digest": digestOf(config), "size": len(config)},
|
||||||
|
"layers": []map[string]any{{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": digestOf(layer), "size": len(layer)}},
|
||||||
|
})
|
||||||
|
dst.manifests[repository+"@"+digestOf(m)] = m
|
||||||
|
return digestOf(m), []string{digestOf(config), digestOf(layer)}
|
||||||
|
}
|
||||||
|
other := "s390x"
|
||||||
|
if runtime.GOARCH == other {
|
||||||
|
other = "amd64"
|
||||||
|
}
|
||||||
|
mine, mineBlobs := put(runtime.GOARCH)
|
||||||
|
theirs, theirBlobs := put(other)
|
||||||
|
index, _ := json.Marshal(map[string]any{
|
||||||
|
"schemaVersion": 2, "mediaType": mediaIndexOCI,
|
||||||
|
"manifests": []map[string]any{
|
||||||
|
{"mediaType": mediaManifestOCI, "digest": mine, "size": 1, "platform": map[string]string{"os": runtime.GOOS, "architecture": runtime.GOARCH}},
|
||||||
|
{"mediaType": mediaManifestOCI, "digest": theirs, "size": 1, "platform": map[string]string{"os": "linux", "architecture": other}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dst.manifests[repository+"@"+digestOf(index)] = index
|
||||||
|
return digestOf(index), mineBlobs, theirBlobs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildsBaseIsLinkedIntoItsRepositoryBeforeThePush(t *testing.T) {
|
||||||
|
dst := newRegistryOfRepositories()
|
||||||
|
server := httptest.NewServer(dst.handler())
|
||||||
|
defer server.Close()
|
||||||
|
address := strings.TrimPrefix(server.URL, "http://")
|
||||||
|
r := Registry{Address: address}
|
||||||
|
|
||||||
|
const from = "upstream/docker.io/library/golang"
|
||||||
|
index, mine, theirs := aBaseForThisMachine(t, dst, from)
|
||||||
|
bases := []string{
|
||||||
|
address + "/" + from + "@" + index,
|
||||||
|
// Not bases this registry holds: a package version, a tag, an image held nowhere here.
|
||||||
|
"@novox/mesh-sdk@1.2.3",
|
||||||
|
"golang:1-alpine",
|
||||||
|
"elsewhere.invalid/thing@sha256:" + strings.Repeat("e", 64),
|
||||||
|
}
|
||||||
|
n, err := r.LendBases(context.Background(), "mesh-tools-go/build", bases)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Every blob of this machine's platform is in the image's repository, so a push asks this machine's
|
||||||
|
// store for none of them; another platform's are not, since nothing built here stands on them.
|
||||||
|
for _, blob := range mine {
|
||||||
|
if !dst.links["mesh-tools-go/build"][blob] {
|
||||||
|
t.Fatalf("the base's blob %s is not in the image's repository", blob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, blob := range theirs {
|
||||||
|
if dst.links["mesh-tools-go/build"][blob] {
|
||||||
|
t.Fatalf("another platform's blob %s was linked", blob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != len(mine) || dst.mounts != len(mine) || dst.uploads != 0 {
|
||||||
|
t.Fatalf("%d linked, %d mounts, %d uploads; want %d mounts and no bytes moved", n, dst.mounts, dst.uploads, len(mine))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asked again, nothing is mounted twice.
|
||||||
|
if n, err := r.LendBases(context.Background(), "mesh-tools-go/build", bases); err != nil || n != 0 {
|
||||||
|
t.Fatalf("a second lending mounted %d: %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A registry that will not mount is said, not hidden: the push is still tried, from the store.
|
||||||
|
func TestABaseTheRegistryWillNotMountIsAnError(t *testing.T) {
|
||||||
|
dst := newRegistryOfRepositories()
|
||||||
|
dst.noMount = true
|
||||||
|
server := httptest.NewServer(dst.handler())
|
||||||
|
defer server.Close()
|
||||||
|
address := strings.TrimPrefix(server.URL, "http://")
|
||||||
|
index, _, _ := aBaseForThisMachine(t, dst, "upstream/thing")
|
||||||
|
_, err := Registry{Address: address}.LendBases(context.Background(), "hello/server",
|
||||||
|
[]string{address + "/upstream/thing@" + index})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "did not mount") {
|
||||||
|
t.Fatalf("a refused mount passed silently: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lending records what a build lent, and when, against the push.
|
||||||
|
type lending struct {
|
||||||
|
*mirroring
|
||||||
|
order []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lending) LendBases(_ context.Context, repository string, bases []string) (int, error) {
|
||||||
|
l.order = append(l.order, "lend "+repository+" "+strings.Join(bases, " "))
|
||||||
|
return len(bases), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lending) PublishImage(ctx context.Context, localTag, repository string) (string, error) {
|
||||||
|
l.order = append(l.order, "push "+repository)
|
||||||
|
return l.mirroring.PublishImage(ctx, localTag, repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnImageIsPushedOnlyAfterItsBasesAreLent(t *testing.T) {
|
||||||
|
r, workspace := aRepository(t, anImage, map[string]string{"modules/bus/Dockerfile": "ARG BASE\nFROM ${BASE}"})
|
||||||
|
r.contents["modules/bus/module.json"] = anImage
|
||||||
|
l := &lending{mirroring: &mirroring{recorded: r, at: "127.0.0.1:5000"}}
|
||||||
|
if _, err := Build(context.Background(), r.run, l, "https://forge.invalid/catalogue.git", "modules/bus", "", workspace,
|
||||||
|
nil, Npmrc{}, GitCredential{}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"lend bus/server 127.0.0.1:5000/upstream/docker.io/vendor/server@sha256:" + strings.Repeat("1", 64),
|
||||||
|
"push bus/server",
|
||||||
|
}
|
||||||
|
if strings.Join(l.order, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Fatalf("the build did:\n%s\nwant:\n%s", strings.Join(l.order, "\n"), strings.Join(want, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A build reads the list of predefined keys at the root of the repository at the commit it builds (novox/hq ADR
|
||||||
|
// 0348 §7, design 57 package K2, issue 661): it refuses a list that is wrong, judges the manifest against it, and
|
||||||
|
// carries it with the commit's generation for the controller to publish.
|
||||||
|
|
||||||
|
const theFirstKeys = `{
|
||||||
|
"font-size": {"type": "number", "description": "the size text is shown in, in points", "minimum": 6, "maximum": 72},
|
||||||
|
"agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// fallingBack is a module of the catalogue whose font size falls back to the predefined key named.
|
||||||
|
func fallingBack(key, kind string) string {
|
||||||
|
def := "10"
|
||||||
|
if kind == "string" {
|
||||||
|
def = `"10"`
|
||||||
|
}
|
||||||
|
return `{"module":"dunst","version":"1",
|
||||||
|
"build":{"artifacts":[{"name":"look","kind":"archive","from":"files"}]},
|
||||||
|
"config":{"font-size":{"type":"` + kind + `","description":"the notification's font size, in points",
|
||||||
|
"default":` + def + `,"predefined":"` + key + `"}},
|
||||||
|
"resources":[
|
||||||
|
{"id":"theme","type":"archive","path":"/opt/dunst","artifact":"look"},
|
||||||
|
{"id":"rc","type":"file","path":"/etc/xdg/dunst/dunstrc","content":"font = Inter ${config:font-size}\n"}]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInTheCatalogue(t *testing.T, manifest string, list *string) (Result, error) {
|
||||||
|
t.Helper()
|
||||||
|
r := &recorded{contents: map[string]string{"modules/dunst/" + ManifestName: manifest,
|
||||||
|
"modules/dunst/files/theme.conf": "dark", "README.md": "the catalogue"}}
|
||||||
|
if list != nil {
|
||||||
|
r.contents[catalogue.PredefinedFile] = *list
|
||||||
|
}
|
||||||
|
return Build(context.Background(), onTrunk{recorded: r}.run, r, "https://forge.invalid/mesh-catalog.git",
|
||||||
|
"modules/dunst", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildNamingAPredefinedKeyIsJudgedAgainstItsTreesListAndCarriesIt(t *testing.T) {
|
||||||
|
list := theFirstKeys
|
||||||
|
got, err := buildInTheCatalogue(t, fallingBack("font-size", "number"), &list)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a module falling back to font-size did not build: %v", err)
|
||||||
|
}
|
||||||
|
if got.Predefined == nil || got.Predefined.Generation != 42 {
|
||||||
|
t.Fatalf("the list was not carried with its generation: %+v", got.Predefined)
|
||||||
|
}
|
||||||
|
carried, err := catalogue.ParsePredefined(got.Predefined.Keys)
|
||||||
|
if err != nil || len(carried) != 2 || carried["font-size"].Type != catalogue.ConfigNumber {
|
||||||
|
t.Fatalf("the list carried is %s (%v)", got.Predefined.Keys, err)
|
||||||
|
}
|
||||||
|
// A change to the list is a change to the module that was judged against it.
|
||||||
|
if len(got.Sources) == 0 || !slices.Contains(got.Sources[0].Paths, catalogue.PredefinedFile) {
|
||||||
|
t.Fatalf("its build source leaves the list out: %+v", got.Sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct{ manifest, says string }{
|
||||||
|
{fallingBack("font-scale", "number"), `"font-scale", which is not on the list`},
|
||||||
|
{fallingBack("font-size", "string"), "which is a number, and this value is a string"},
|
||||||
|
} {
|
||||||
|
if _, err := buildInTheCatalogue(t, c.manifest, &list); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("wanted the build refused saying %q, got %v", c.says, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildRefusesAWrongList(t *testing.T) {
|
||||||
|
wrong := `{"font-size": {"type": "number", "description": "points", "unit": "pt"}}`
|
||||||
|
if _, err := buildInTheCatalogue(t, fallingBack("font-size", "number"), &wrong); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), `unknown field "unit"`) {
|
||||||
|
t.Fatalf("a wrong list was built with: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A repository without the list builds as before, carrying none; a value naming a key there is refused.
|
||||||
|
func TestABuildWithoutAListCarriesNone(t *testing.T) {
|
||||||
|
plain := strings.Replace(fallingBack("font-size", "number"), `,"predefined":"font-size"`, "", 1)
|
||||||
|
got, err := buildInTheCatalogue(t, plain, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Predefined != nil {
|
||||||
|
t.Fatalf("a list was carried from a tree holding none: %+v", got.Predefined)
|
||||||
|
}
|
||||||
|
if slices.Contains(got.Sources[0].Paths, catalogue.PredefinedFile) {
|
||||||
|
t.Fatalf("the build source names a list: %+v", got.Sources)
|
||||||
|
}
|
||||||
|
if _, err := buildInTheCatalogue(t, fallingBack("font-size", "number"), nil); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "is not present") {
|
||||||
|
t.Fatalf("a key named where no list is: %v", err)
|
||||||
|
}
|
||||||
|
// A list present and a module naming no key: carried, and not the module's build source.
|
||||||
|
list := theFirstKeys
|
||||||
|
got, err = buildInTheCatalogue(t, plain, &list)
|
||||||
|
if err != nil || got.Predefined == nil || slices.Contains(got.Sources[0].Paths, catalogue.PredefinedFile) {
|
||||||
|
t.Fatalf("a module naming no key: %+v %+v %v", got.Predefined, got.Sources, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/artifacts"
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One build per change (novox/hq ADR 0350).
|
||||||
|
//
|
||||||
|
// **The merge check builds what the merge would move** (§1): every artifact of every module the delivery plan
|
||||||
|
// moves, from the merged tree it checks, into the artifact store's scratch namespace — never a module's own —
|
||||||
|
// kept under a **reuse key**: the source fingerprint as the build takes it (the module's tree, its contexts' trees,
|
||||||
|
// its bases and toolchains by digest; issue 280) with the builder's own version and, for an upstream artifact, the
|
||||||
|
// image it copies. **The walk of a trunk commit takes that build** (§2): before building, it takes the reuse key
|
||||||
|
// of the trunk commit's tree the same way, and where a check build holds the same key it copies it by digest into
|
||||||
|
// the module's namespace and answers with that, building nothing. Otherwise it builds and says why (§3): no check
|
||||||
|
// built it, the key differs (naming the part), or the check build is gone; a copy that fails is said and built.
|
||||||
|
//
|
||||||
|
// **What the key names is what a fresh build could differ by.** The tree is every file the recipe and the
|
||||||
|
// compiler see; the contexts are the other repositories it reads, by tree; the bases and toolchains are what it
|
||||||
|
// stands on, by digest; the builder's version is how it is built. A build whose source does not pin it — one that
|
||||||
|
// resolves packages from the mesh's registry when it is built, or copies an upstream image named by tag — has no
|
||||||
|
// key, and is always built.
|
||||||
|
|
||||||
|
// Version is this builder's own version, as the reuse key takes it: the digest of the running executable, so any
|
||||||
|
// change to how it builds is a change of key (§1). Set once by the build seat's holder (SelfVersion); empty — no
|
||||||
|
// key, every build its own — when it cannot be said.
|
||||||
|
var Version string
|
||||||
|
|
||||||
|
// SelfVersion is the version the reuse key takes: the running executable's digest, or the version stamped at build
|
||||||
|
// when the executable cannot be read. Empty when neither says anything ("development" says nothing: every unstamped
|
||||||
|
// builder would read as one).
|
||||||
|
func SelfVersion(stamped string) string {
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
if f, err := os.Open(exe); err == nil {
|
||||||
|
defer f.Close()
|
||||||
|
sum := sha256.New()
|
||||||
|
if _, err := io.Copy(sum, f); err == nil {
|
||||||
|
return "exe:sha256:" + hex.EncodeToString(sum.Sum(nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stamped == "" || stamped == "development" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return stamped
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScratchPrefix is the namespace a check builds into (novox/hq ADR 0239 §9, ADR 0350 §1): `scratch/<module>/<artifact>`.
|
||||||
|
// Registration refuses a reference into it; only the walk's copy, in the module's own namespace, is registered.
|
||||||
|
const ScratchPrefix = "scratch/"
|
||||||
|
|
||||||
|
// checksRepository holds one document per check that built: which builds it made, of which pull request, when.
|
||||||
|
// What the holder lets go of by (§5).
|
||||||
|
const checksRepository = ScratchPrefix + "checks"
|
||||||
|
|
||||||
|
// reuseTagPrefix names a check build's record under its module's scratch repository: `r1-<hex>`.
|
||||||
|
const reuseTagPrefix = "r1-"
|
||||||
|
|
||||||
|
// CheckBuildsKept is how long a check build is kept in the scratch namespace (§5).
|
||||||
|
const CheckBuildsKept = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// The parts of a reuse key, as the walk names the one that differs (§3).
|
||||||
|
const (
|
||||||
|
partModule = "the module"
|
||||||
|
partTree = "the build source"
|
||||||
|
partBases = "a base image"
|
||||||
|
partBuilder = "the builder's version"
|
||||||
|
partContext = "a context"
|
||||||
|
partTool = "a toolchain"
|
||||||
|
partUpstream = "an upstream image"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reuseKey is a build's reuse key and the parts it was made from; Key empty, with why, when it has none.
|
||||||
|
type reuseKey struct {
|
||||||
|
Key string
|
||||||
|
Parts map[string]string
|
||||||
|
Why string
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyOf is the reuse key of what src says the build was made from, built by version (ADR 0350 §1).
|
||||||
|
func keyOf(src *sourceInputs, manifest catalogue.Manifest, version string) reuseKey {
|
||||||
|
if src == nil || src.unpinned != "" || src.tree == "" {
|
||||||
|
why := "its source does not pin the build"
|
||||||
|
if src != nil && src.unpinned != "" {
|
||||||
|
why = src.unpinned
|
||||||
|
} else if src != nil && src.tree == "" {
|
||||||
|
why = "its tree could not be named"
|
||||||
|
}
|
||||||
|
return reuseKey{Why: why}
|
||||||
|
}
|
||||||
|
if version == "" {
|
||||||
|
return reuseKey{Why: "the builder's version cannot be said"}
|
||||||
|
}
|
||||||
|
parts := map[string]string{partModule: src.module, partTree: src.tree, partBuilder: version}
|
||||||
|
var bases []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, b := range src.bases {
|
||||||
|
if d := referenceDigest(b); !seen[d] {
|
||||||
|
seen[d] = true
|
||||||
|
bases = append(bases, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(bases)
|
||||||
|
parts[partBases] = strings.Join(bases, " ")
|
||||||
|
for a, t := range src.contexts {
|
||||||
|
parts[partContext+" "+a] = t
|
||||||
|
}
|
||||||
|
for a, t := range src.toolchains {
|
||||||
|
parts[partTool+" "+a] = t
|
||||||
|
}
|
||||||
|
// An upstream artifact is copied, not built: what it copies is in module.json, and so in the tree — but a tag
|
||||||
|
// names whatever its registry says today, so only an image pinned by digest is the same image tomorrow.
|
||||||
|
if manifest.Build != nil {
|
||||||
|
for _, a := range manifest.Build.Artifacts {
|
||||||
|
if a.Kind != catalogue.ArtifactUpstream {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(a.From, "@sha256:") {
|
||||||
|
return reuseKey{Why: fmt.Sprintf("%s copies %s, named by tag, which can move", a.Name, a.From)}
|
||||||
|
}
|
||||||
|
parts[partUpstream+" "+a.Name] = a.From
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines := make([]string, 0, len(parts))
|
||||||
|
for k, v := range parts {
|
||||||
|
lines = append(lines, k+"="+v)
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||||
|
return reuseKey{Key: "r1:" + hex.EncodeToString(sum[:]), Parts: parts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagOf is the tag a key's record is kept under.
|
||||||
|
func tagOf(key string) string { return reuseTagPrefix + strings.TrimPrefix(key, "r1:") }
|
||||||
|
|
||||||
|
// differing names the parts of two keys that differ, in a fixed order: what the walk says when it builds (§3).
|
||||||
|
func differing(was, now map[string]string) []string {
|
||||||
|
names := map[string]bool{}
|
||||||
|
for k := range was {
|
||||||
|
names[k] = true
|
||||||
|
}
|
||||||
|
for k := range now {
|
||||||
|
names[k] = true
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for k := range names {
|
||||||
|
if was[k] != now[k] {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullRef names a pull request.
|
||||||
|
type PullRef struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PullRef) String() string { return fmt.Sprintf("%s/%s#%d", p.Owner, p.Repo, p.Number) }
|
||||||
|
|
||||||
|
// slug is the pull request as a tag's first part: owner.repo.number, every other character a dash.
|
||||||
|
func (p PullRef) slug() string {
|
||||||
|
clean := func(s string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return '-'
|
||||||
|
}, s)
|
||||||
|
}
|
||||||
|
return clean(p.Owner) + "." + clean(p.Repo) + "." + strconv.Itoa(p.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckBuild is one module a check built into the scratch namespace, kept under its reuse key (§1).
|
||||||
|
type CheckBuild struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Parts map[string]string `json:"parts"`
|
||||||
|
// Made are its artifacts, in the scratch namespace.
|
||||||
|
Made []catalogue.Built `json:"made"`
|
||||||
|
// Pull is the pull request checked, Head its head and Trunk the trunk commit it was merged into; Check the
|
||||||
|
// check's ask; At when it was built.
|
||||||
|
Pull PullRef `json:"pull"`
|
||||||
|
Head string `json:"head,omitempty"`
|
||||||
|
Trunk string `json:"trunk,omitempty"`
|
||||||
|
Check string `json:"check"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Said is the check build as the walk names it: "the check of owner/repo#n at head H (trunk T)".
|
||||||
|
func (c CheckBuild) Said() string {
|
||||||
|
s := "the check of " + c.Pull.String()
|
||||||
|
if c.Head != "" {
|
||||||
|
s += " at head " + twelve(c.Head)
|
||||||
|
}
|
||||||
|
if c.Trunk != "" {
|
||||||
|
s += " (merged into trunk " + twelve(c.Trunk) + ")"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkIndex is one check's document under checksRepository: every build it made.
|
||||||
|
type checkIndex struct {
|
||||||
|
Pull PullRef `json:"pull"`
|
||||||
|
Check string `json:"check"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Builds []CheckBuild `json:"builds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scratch is where check builds are kept and taken from: the artifact store's scratch namespace (ADR 0350). The
|
||||||
|
// Registry is one; a test's is a map.
|
||||||
|
type Scratch interface {
|
||||||
|
// Keep records a check's builds: each under its module and key, and the check's own document.
|
||||||
|
Keep(ctx context.Context, pull PullRef, check string, at time.Time, builds []CheckBuild) error
|
||||||
|
// Find is the check build kept under a module's key; false when none is.
|
||||||
|
Find(ctx context.Context, module, key string) (CheckBuild, bool, error)
|
||||||
|
// OfPulls are the check builds kept for those pull requests, newest first: what the walk names a differing
|
||||||
|
// key from.
|
||||||
|
OfPulls(ctx context.Context, pulls []PullRef) ([]CheckBuild, error)
|
||||||
|
// Take copies one artifact of a check build by digest into the module's own namespace, and answers it there.
|
||||||
|
Take(ctx context.Context, module string, made catalogue.Built) (catalogue.Built, error)
|
||||||
|
// Forget lets go of every check build of a pull request, and Expire of every check older than the bound;
|
||||||
|
// each answers how many checks it let go of.
|
||||||
|
Forget(ctx context.Context, pull PullRef) (int, error)
|
||||||
|
Expire(ctx context.Context, older time.Duration, now time.Time) (int, error)
|
||||||
|
// Discard lets go of what builds a check could not keep made: nothing would ever find them.
|
||||||
|
Discard(ctx context.Context, builds []CheckBuild) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// scratchPublisher is a Publisher into the scratch namespace: what a check builds is never under a module's
|
||||||
|
// own name (ADR 0239 §9). A base the build stands on is copied where every build copies it.
|
||||||
|
type scratchPublisher struct{ to Publisher }
|
||||||
|
|
||||||
|
func (s scratchPublisher) PublishImage(ctx context.Context, localTag, repository string) (string, error) {
|
||||||
|
return s.to.PublishImage(ctx, localTag, ScratchPrefix+repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s scratchPublisher) PublishArchive(ctx context.Context, repository string, body []byte, digest string) (string, error) {
|
||||||
|
return s.to.PublishArchive(ctx, ScratchPrefix+repository, body, digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s scratchPublisher) LendBases(ctx context.Context, repository string, bases []string) (int, error) {
|
||||||
|
if l, can := s.to.(BaseLender); can {
|
||||||
|
return l.LendBases(ctx, ScratchPrefix+repository, bases)
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s scratchPublisher) MirrorImage(ctx context.Context, from, repository string) (string, error) {
|
||||||
|
m, can := s.to.(Mirrorer)
|
||||||
|
if !can {
|
||||||
|
return "", errors.New("the artifact store here cannot copy an upstream image")
|
||||||
|
}
|
||||||
|
return m.MirrorImage(ctx, from, ScratchPrefix+repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s scratchPublisher) MirrorBase(ctx context.Context, from, repository, former string) (string, error) {
|
||||||
|
if m, can := s.to.(BaseMirrorer); can {
|
||||||
|
return m.MirrorBase(ctx, from, repository, former)
|
||||||
|
}
|
||||||
|
if m, can := s.to.(Mirrorer); can {
|
||||||
|
return m.MirrorImage(ctx, from, repository)
|
||||||
|
}
|
||||||
|
return "", errors.New("the artifact store here cannot copy a base")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The registry as the scratch namespace.
|
||||||
|
|
||||||
|
func (r Registry) store() artifacts.Store {
|
||||||
|
return artifacts.Store{Address: r.Address, HTTP: r.client()}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordMedia = "application/vnd.novox.mesh.check-build.v1+json"
|
||||||
|
|
||||||
|
// Keep puts each build's record under its module's scratch repository, by key, and the check's document.
|
||||||
|
func (r Registry) Keep(ctx context.Context, pull PullRef, check string, at time.Time, builds []CheckBuild) error {
|
||||||
|
store := r.store()
|
||||||
|
for _, b := range builds {
|
||||||
|
body, err := json.Marshal(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := store.PutTagged(ctx, ScratchPrefix+b.Module, tagOf(b.Key), recordMedia, body); err != nil {
|
||||||
|
return fmt.Errorf("keeping %s's check build: %w", b.Module, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(checkIndex{Pull: pull, Check: check, At: at, Builds: builds})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := store.PutTagged(ctx, checksRepository, pull.slug()+"."+check, recordMedia, body); err != nil {
|
||||||
|
return fmt.Errorf("keeping which builds the check of %s made: %w", pull, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find reads the record kept under a module's key.
|
||||||
|
func (r Registry) Find(ctx context.Context, module, key string) (CheckBuild, bool, error) {
|
||||||
|
body, _, err := r.store().GetTagged(ctx, ScratchPrefix+module, tagOf(key))
|
||||||
|
if errors.Is(err, artifacts.ErrNoTag) {
|
||||||
|
return CheckBuild{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return CheckBuild{}, false, err
|
||||||
|
}
|
||||||
|
var b CheckBuild
|
||||||
|
if err := json.Unmarshal(body, &b); err != nil {
|
||||||
|
return CheckBuild{}, false, fmt.Errorf("%s's check build under %s is not one: %w", module, tagOf(key), err)
|
||||||
|
}
|
||||||
|
if b.Key != key || b.Module != module {
|
||||||
|
return CheckBuild{}, false, nil
|
||||||
|
}
|
||||||
|
return b, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checks are the check documents kept, by tag; only those of the pull requests named when any are.
|
||||||
|
func (r Registry) checks(ctx context.Context, pulls []PullRef) (map[string]checkIndex, error) {
|
||||||
|
store := r.store()
|
||||||
|
tags, err := store.Tags(ctx, checksRepository)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wanted := map[string]PullRef{}
|
||||||
|
for _, p := range pulls {
|
||||||
|
wanted[p.slug()+"."] = p
|
||||||
|
}
|
||||||
|
out := map[string]checkIndex{}
|
||||||
|
for _, tag := range tags {
|
||||||
|
if len(pulls) > 0 {
|
||||||
|
match := false
|
||||||
|
for prefix := range wanted {
|
||||||
|
match = match || strings.HasPrefix(tag, prefix)
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, _, err := store.GetTagged(ctx, checksRepository, tag)
|
||||||
|
if errors.Is(err, artifacts.ErrNoTag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var c checkIndex
|
||||||
|
if err := json.Unmarshal(body, &c); err != nil {
|
||||||
|
continue // not a document this wrote: left to nobody, said by nothing — it names no build
|
||||||
|
}
|
||||||
|
if len(pulls) > 0 {
|
||||||
|
if p := wanted[c.Pull.slug()+"."]; p != c.Pull {
|
||||||
|
continue // a slug two pull requests share: only the one named
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[tag] = c
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfPulls are the check builds of those pull requests, newest first.
|
||||||
|
func (r Registry) OfPulls(ctx context.Context, pulls []PullRef) ([]CheckBuild, error) {
|
||||||
|
if len(pulls) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
checks, err := r.checks(ctx, pulls)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []CheckBuild
|
||||||
|
for _, c := range checks {
|
||||||
|
out = append(out, c.Builds...)
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].At.After(out[j].At) })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take copies one artifact of a check build into the module's namespace by digest: an image's manifests as they
|
||||||
|
// are, so the digest registered is the digest the check built; an archive's blob mounted across, and held.
|
||||||
|
func (r Registry) Take(ctx context.Context, module string, made catalogue.Built) (catalogue.Built, error) {
|
||||||
|
repository := module + "/" + made.Name
|
||||||
|
from := ScratchPrefix + repository
|
||||||
|
if made.Digest != "" || strings.Contains(made.Reference, "/blobs/") {
|
||||||
|
digest := made.Digest
|
||||||
|
if digest == "" {
|
||||||
|
_, digest, _ = strings.Cut(made.Reference, "/blobs/")
|
||||||
|
}
|
||||||
|
where := upstream{base: "http://" + r.Address, repository: from, reference: digest}
|
||||||
|
if err := r.copyBlob(ctx, &source{client: r.client(), mountFrom: from}, where, digest, repository); err != nil {
|
||||||
|
return catalogue.Built{}, err
|
||||||
|
}
|
||||||
|
size, err := r.blobSize(ctx, repository, digest)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Built{}, err
|
||||||
|
}
|
||||||
|
if err := r.hold(ctx, repository, digest, int(size)); err != nil {
|
||||||
|
return catalogue.Built{}, err
|
||||||
|
}
|
||||||
|
return catalogue.Built{Name: made.Name, Kind: made.Kind, Digest: digest, Launchers: made.Launchers,
|
||||||
|
Reference: "http://" + r.Address + "/v2/" + repository + "/blobs/" + digest}, nil
|
||||||
|
}
|
||||||
|
_, digest, pinned := strings.Cut(made.Reference, "@")
|
||||||
|
if !pinned || !strings.HasPrefix(digest, "sha256:") {
|
||||||
|
return catalogue.Built{}, fmt.Errorf("%s's check build %s is not pinned by digest", module, made.Reference)
|
||||||
|
}
|
||||||
|
reference, err := r.MirrorBase(ctx, r.Address+"/"+from+"@"+digest, repository, from)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Built{}, err
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(reference, "@"+digest) {
|
||||||
|
return catalogue.Built{}, fmt.Errorf("%s's check build was copied as %s, not as %s", module, reference, digest)
|
||||||
|
}
|
||||||
|
return catalogue.Built{Name: made.Name, Kind: made.Kind, Reference: reference, Launchers: made.Launchers}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// blobSize is the size the store says a blob has.
|
||||||
|
func (r Registry) blobSize(ctx context.Context, repository, digest string) (int64, error) {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodHead, "http://"+r.Address+"/v2/"+repository+"/blobs/"+digest, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
response, err := r.client().Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK || response.ContentLength < 0 {
|
||||||
|
return 0, fmt.Errorf("%s/blobs/%s: %s", repository, digest, response.Status)
|
||||||
|
}
|
||||||
|
return response.ContentLength, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget lets go of every check build of a pull request (§5): what each check built in the scratch namespace, the
|
||||||
|
// record under its key when it is still that check's, and the check's document.
|
||||||
|
func (r Registry) Forget(ctx context.Context, pull PullRef) (int, error) {
|
||||||
|
all, err := r.checks(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
mine := map[string]checkIndex{}
|
||||||
|
for tag, c := range all {
|
||||||
|
if c.Pull == pull {
|
||||||
|
mine[tag] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.letGo(ctx, mine, all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire lets go of every check older than the bound (§5).
|
||||||
|
func (r Registry) Expire(ctx context.Context, older time.Duration, now time.Time) (int, error) {
|
||||||
|
checks, err := r.checks(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
old := map[string]checkIndex{}
|
||||||
|
for tag, c := range checks {
|
||||||
|
if now.Sub(c.At) > older {
|
||||||
|
old[tag] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.letGo(ctx, old, checks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard lets go of builds no check document names.
|
||||||
|
func (r Registry) Discard(ctx context.Context, builds []CheckBuild) error {
|
||||||
|
all, err := r.checks(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = r.letGo(ctx, map[string]checkIndex{"": {Builds: builds}}, all)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// letGo lets go of each check's builds, then its document; a check whose builds could not all be let go of keeps
|
||||||
|
// its document, so the next sweep asks again. **A build another check still names stays**: deletion is by digest,
|
||||||
|
// and two checks that built one digest in one repository — a check asked again, a dependent two pull requests move —
|
||||||
|
// share it; it goes with the last check that names it. all are every check kept, the ones going among them.
|
||||||
|
func (r Registry) letGo(ctx context.Context, checks, all map[string]checkIndex) (int, error) {
|
||||||
|
store := r.store()
|
||||||
|
stays := map[string]bool{}
|
||||||
|
for tag, c := range all {
|
||||||
|
if _, going := checks[tag]; going {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, b := range c.Builds {
|
||||||
|
for _, m := range b.Made {
|
||||||
|
stays[catalogue.Recorded(m.Reference)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gone := 0
|
||||||
|
var failed []string
|
||||||
|
for tag, c := range checks {
|
||||||
|
ok := true
|
||||||
|
for _, b := range c.Builds {
|
||||||
|
for _, m := range b.Made {
|
||||||
|
reference := catalogue.Recorded(m.Reference)
|
||||||
|
if path, kept := catalogue.InArtifactStore(reference); !kept || !strings.HasPrefix(path, ScratchPrefix) {
|
||||||
|
continue // never anything outside the scratch namespace
|
||||||
|
}
|
||||||
|
if stays[reference] {
|
||||||
|
continue // another check still names it
|
||||||
|
}
|
||||||
|
if err := store.LetGo(ctx, reference); err != nil && !errors.Is(err, artifacts.Gone) {
|
||||||
|
ok = false
|
||||||
|
failed = append(failed, fmt.Sprintf("%s: %v", m.Reference, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The record under the key, only while it is still this check's: a later check of the same key
|
||||||
|
// replaced it, and that one is its own check's to let go of.
|
||||||
|
if kept, found, err := r.Find(ctx, b.Module, b.Key); err == nil && found && kept.Check == c.Check {
|
||||||
|
if err := store.DeleteTagged(ctx, ScratchPrefix+b.Module, tagOf(b.Key)); err != nil && !errors.Is(err, artifacts.Gone) {
|
||||||
|
ok = false
|
||||||
|
failed = append(failed, fmt.Sprintf("%s's record: %v", b.Module, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tag == "" {
|
||||||
|
gone++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := store.DeleteTagged(ctx, checksRepository, tag); err != nil && !errors.Is(err, artifacts.Gone) {
|
||||||
|
failed = append(failed, fmt.Sprintf("%s: %v", tag, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gone++
|
||||||
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
sort.Strings(failed)
|
||||||
|
return gone, fmt.Errorf("not let go of: %s", strings.Join(failed, "; "))
|
||||||
|
}
|
||||||
|
return gone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reused is what the walk took from a check instead of building (§2, §4).
|
||||||
|
type Reused struct {
|
||||||
|
From CheckBuild
|
||||||
|
// Made are the copies in the module's namespace: what is registered.
|
||||||
|
Made []catalogue.Built
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words is the walk's word for a module it took: "reused from <check build>", with each digest (§4).
|
||||||
|
func (r Reused) Words() string {
|
||||||
|
var digests []string
|
||||||
|
for _, m := range r.Made {
|
||||||
|
d := m.Digest
|
||||||
|
if d == "" {
|
||||||
|
_, d, _ = strings.Cut(m.Reference, "@")
|
||||||
|
}
|
||||||
|
digests = append(digests, m.Name+" "+d)
|
||||||
|
}
|
||||||
|
return "reused from " + r.From.Said() + ": " + strings.Join(digests, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamedBuild is a build a check's verdict names (ADR 0350 §2): its module, key and each artifact's digest.
|
||||||
|
type NamedBuild struct {
|
||||||
|
Pull PullRef
|
||||||
|
Check string
|
||||||
|
Module string
|
||||||
|
Key string
|
||||||
|
Digests map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DigestOf is the digest a reference names: an image's after `@`, an archive's after `/blobs/`.
|
||||||
|
func DigestOf(reference string) string {
|
||||||
|
if _, d, ok := strings.Cut(reference, "/blobs/"); ok {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if _, d, ok := strings.Cut(reference, "@"); ok {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// unnamed is why a record found under a key is not taken: no verdict of the walk's pull requests names that module,
|
||||||
|
// key and every one of its digests. Empty when one does.
|
||||||
|
func unnamed(named []NamedBuild, module, key string, found CheckBuild) string {
|
||||||
|
for _, n := range named {
|
||||||
|
if n.Module != module || n.Key != key || n.Check != found.Check || len(n.Digests) != len(found.Made) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
same := true
|
||||||
|
for _, m := range found.Made {
|
||||||
|
d := m.Digest
|
||||||
|
if d == "" {
|
||||||
|
d = DigestOf(m.Reference)
|
||||||
|
}
|
||||||
|
same = same && d != "" && n.Digests[m.Name] == d
|
||||||
|
}
|
||||||
|
if same {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "a build is kept under this key, and no check verdict of the walk's pull requests names it with its " +
|
||||||
|
"digests: not taken"
|
||||||
|
}
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One build per change (novox/hq ADR 0350): the merge check builds what the merge would move into the scratch
|
||||||
|
// namespace under its reuse key, and the walk of the trunk commit takes that build by digest instead of building.
|
||||||
|
|
||||||
|
// aScratch is the scratch namespace in memory: check builds by module and key, and what the walk took.
|
||||||
|
type aScratch struct {
|
||||||
|
builds map[string]CheckBuild
|
||||||
|
took []string
|
||||||
|
failTake bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newScratch() *aScratch { return &aScratch{builds: map[string]CheckBuild{}} }
|
||||||
|
|
||||||
|
func (s *aScratch) Keep(_ context.Context, _ PullRef, _ string, _ time.Time, builds []CheckBuild) error {
|
||||||
|
for _, b := range builds {
|
||||||
|
s.builds[b.Module+" "+b.Key] = b
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *aScratch) Find(_ context.Context, module, key string) (CheckBuild, bool, error) {
|
||||||
|
b, ok := s.builds[module+" "+key]
|
||||||
|
return b, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *aScratch) OfPulls(_ context.Context, pulls []PullRef) ([]CheckBuild, error) {
|
||||||
|
var out []CheckBuild
|
||||||
|
for _, b := range s.builds {
|
||||||
|
if slices.Contains(pulls, b.Pull) {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *aScratch) Take(_ context.Context, module string, made catalogue.Built) (catalogue.Built, error) {
|
||||||
|
if s.failTake {
|
||||||
|
return catalogue.Built{}, errors.New("the store refused the mount")
|
||||||
|
}
|
||||||
|
s.took = append(s.took, made.Reference)
|
||||||
|
made.Reference = strings.Replace(made.Reference, ScratchPrefix, "", 1)
|
||||||
|
return made, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *aScratch) Forget(context.Context, PullRef) (int, error) { return 0, nil }
|
||||||
|
func (s *aScratch) Expire(context.Context, time.Duration, time.Time) (int, error) { return 0, nil }
|
||||||
|
func (s *aScratch) Discard(context.Context, []CheckBuild) error { return nil }
|
||||||
|
|
||||||
|
const reusedModule = `{"module":"meshboard","version":"1",
|
||||||
|
"build":{"artifacts":[
|
||||||
|
{"name":"server","kind":"image","from":"Dockerfile"},
|
||||||
|
{"name":"look","kind":"archive","from":"files"}]},
|
||||||
|
"resources":[
|
||||||
|
{"id":"svc","type":"container","name":"meshboard","artifact":"server"},
|
||||||
|
{"id":"theme","type":"archive","path":"/opt/meshboard","artifact":"look"}]}`
|
||||||
|
|
||||||
|
// aModuleAt is a repository holding the module under mod/, whose directory git names as tree, on its trunk.
|
||||||
|
func aModuleAt(t *testing.T, tree string) (*recorded, Runner, string) {
|
||||||
|
t.Helper()
|
||||||
|
return aModuleWith(t, tree, map[string]string{"mod/" + ManifestName: reusedModule,
|
||||||
|
"mod/Dockerfile": "FROM scratch", "mod/files/theme.conf": "dark"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// aModuleWith is aModuleAt with the repository's files given.
|
||||||
|
func aModuleWith(t *testing.T, tree string, contents map[string]string) (*recorded, Runner, string) {
|
||||||
|
t.Helper()
|
||||||
|
r := &recorded{tree: tree, contents: contents}
|
||||||
|
run := func(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||||
|
if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" {
|
||||||
|
r.ran = append(r.ran, name+" "+strings.Join(args, " "))
|
||||||
|
return "origin/main\n", nil
|
||||||
|
}
|
||||||
|
return r.run(ctx, dir, name, args...)
|
||||||
|
}
|
||||||
|
return r, run, t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerBuilt(r *recorded) bool {
|
||||||
|
for _, line := range r.ran {
|
||||||
|
if strings.HasPrefix(line, "docker build") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var aPull = PullRef{Owner: "novox", Repo: "meshboard", Number: 7}
|
||||||
|
|
||||||
|
// aCheckBuilt is the check of aPull building the module from a tree, into the scratch namespace.
|
||||||
|
func aCheckBuilt(t *testing.T, scratch *aScratch, tree string) CheckBuilt {
|
||||||
|
t.Helper()
|
||||||
|
r, run, workspace := aModuleAt(t, tree)
|
||||||
|
return aCheckBuiltBy(t, scratch, r, run, workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aCheckBuiltBy is aCheckBuilt of the repository given.
|
||||||
|
func aCheckBuiltBy(t *testing.T, scratch *aScratch, r *recorded, run Runner, workspace string) CheckBuilt {
|
||||||
|
t.Helper()
|
||||||
|
spec := CheckSpec{ID: "build-1", Owner: aPull.Owner, Repo: aPull.Repo, Number: aPull.Number,
|
||||||
|
Builds: []CheckBuildSpec{{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod",
|
||||||
|
Ref: "main"}}}
|
||||||
|
built := buildForTheMerge(t.Context(), run, spec, CheckBuilding{Publish: r, Scratch: scratch}, workspace, "", "",
|
||||||
|
CheckVerdict{Head: "abcdefabcdef", Trunk: "123456123456"}, GitCredential{}, nil, logging(nil))
|
||||||
|
if len(built) != 1 || built[0].NotBuilt != "" || built[0].Key == "" {
|
||||||
|
t.Fatalf("the check did not build the module for its merge: %+v", built)
|
||||||
|
}
|
||||||
|
if !dockerBuilt(r) {
|
||||||
|
t.Fatal("the check built no image")
|
||||||
|
}
|
||||||
|
for repository := range r.images {
|
||||||
|
if !strings.HasPrefix(repository, ScratchPrefix) {
|
||||||
|
t.Fatalf("the check published %s outside the scratch namespace", repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for repository := range r.archives {
|
||||||
|
if !strings.HasPrefix(repository, ScratchPrefix) {
|
||||||
|
t.Fatalf("the check published %s outside the scratch namespace", repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return built[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// named is the walk's leave naming what a check's verdict says it built, as the controller reads it.
|
||||||
|
func named(c CheckBuilt) []NamedBuild {
|
||||||
|
n := NamedBuild{Pull: aPull, Check: "build-1", Module: c.Module, Key: c.Key, Digests: map[string]string{}}
|
||||||
|
for _, m := range c.Made {
|
||||||
|
d := m.Digest
|
||||||
|
if d == "" {
|
||||||
|
d = DigestOf(m.Reference)
|
||||||
|
}
|
||||||
|
n.Digests[m.Name] = d
|
||||||
|
}
|
||||||
|
return []NamedBuild{n}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withVersion(t *testing.T, v string) {
|
||||||
|
t.Helper()
|
||||||
|
was := Version
|
||||||
|
Version = v
|
||||||
|
t.Cleanup(func() { Version = was })
|
||||||
|
}
|
||||||
|
|
||||||
|
// A merge whose reuse key equals its check's builds nothing again: the walk copies the check's build by digest
|
||||||
|
// and registers that digest.
|
||||||
|
func TestAWalkWhoseKeyIsItsChecksBuildsNothingAndRegistersTheChecksDigest(t *testing.T) {
|
||||||
|
withVersion(t, "builder-1")
|
||||||
|
scratch := newScratch()
|
||||||
|
checked := aCheckBuilt(t, scratch, "tree-one")
|
||||||
|
|
||||||
|
r, run, workspace := aModuleAt(t, "tree-one")
|
||||||
|
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: named(checked)},
|
||||||
|
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dockerBuilt(r) || len(r.images) > 0 || len(r.archives) > 0 {
|
||||||
|
t.Fatalf("the walk built again: ran %v, published %v %v", r.ran, r.images, r.archives)
|
||||||
|
}
|
||||||
|
if got.Reused == nil || got.Why != "" {
|
||||||
|
t.Fatalf("the walk did not say it reused the check's build: reused %+v, why %q", got.Reused, got.Why)
|
||||||
|
}
|
||||||
|
if got.ReuseKey != checked.Key {
|
||||||
|
t.Fatalf("the walk's key %s is not the check's %s", got.ReuseKey, checked.Key)
|
||||||
|
}
|
||||||
|
if len(scratch.took) != 2 {
|
||||||
|
t.Fatalf("the walk copied %v, not the check's two artifacts", scratch.took)
|
||||||
|
}
|
||||||
|
for _, made := range checked.Made {
|
||||||
|
registered := strings.Replace(made.Reference, ScratchPrefix, "", 1)
|
||||||
|
found := false
|
||||||
|
for _, b := range got.Built {
|
||||||
|
found = found || (b.Reference == registered && b.Digest == made.Digest)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("the walk registers %+v, not the check's %s copied", got.Built, made.Reference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if image, _ := got.Manifest.Resources[0]["image"].(string); image != "registry.invalid/meshboard/server@sha256:"+strings.Repeat("a", 64) {
|
||||||
|
t.Fatalf("the manifest pins %q, not the check's digest in the module's namespace", image)
|
||||||
|
}
|
||||||
|
words := got.Reused.Words()
|
||||||
|
if !strings.HasPrefix(words, "reused from the check of novox/meshboard#7 at head abcdefabcdef (merged into trunk 123456123456)") ||
|
||||||
|
!strings.Contains(words, "sha256:") {
|
||||||
|
t.Fatalf("the walk says %q", words)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A record no verdict names is refused and built instead, and the walk says so: the store takes writes from anything
|
||||||
|
// on its network, so a record planted under another merge's key is never registered.
|
||||||
|
func TestARecordNoVerdictNamesIsRefusedAndBuilt(t *testing.T) {
|
||||||
|
withVersion(t, "builder-1")
|
||||||
|
scratch := newScratch()
|
||||||
|
checked := aCheckBuilt(t, scratch, "tree-one")
|
||||||
|
for name, leave := range map[string][]NamedBuild{
|
||||||
|
"no verdict": nil,
|
||||||
|
"another digest": func() []NamedBuild {
|
||||||
|
n := named(checked)
|
||||||
|
n[0].Digests["server"] = "sha256:" + strings.Repeat("e", 64)
|
||||||
|
return n
|
||||||
|
}(),
|
||||||
|
"another check": func() []NamedBuild { n := named(checked); n[0].Check = "build-9"; return n }(),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
scratch.took = nil
|
||||||
|
r, run, workspace := aModuleAt(t, "tree-one")
|
||||||
|
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: leave},
|
||||||
|
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Reused != nil || len(scratch.took) > 0 || !dockerBuilt(r) {
|
||||||
|
t.Fatalf("a record no verdict names was taken: %+v, copied %v", got.Reused, scratch.took)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Why, "no check verdict of the walk's pull requests names it") {
|
||||||
|
t.Fatalf("the walk says %q", got.Why)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A differing key builds, and says which part differs.
|
||||||
|
func TestAWalkWhoseKeyDiffersBuildsAndSaysWhy(t *testing.T) {
|
||||||
|
withVersion(t, "builder-1")
|
||||||
|
scratch := newScratch()
|
||||||
|
checked := aCheckBuilt(t, scratch, "tree-one")
|
||||||
|
|
||||||
|
r, run, workspace := aModuleAt(t, "tree-two")
|
||||||
|
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: named(checked)},
|
||||||
|
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !dockerBuilt(r) || got.Reused != nil {
|
||||||
|
t.Fatalf("a walk whose source differs did not build: reused %+v", got.Reused)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Why, "the key differs") || !strings.Contains(got.Why, partTree) {
|
||||||
|
t.Fatalf("the walk says %q, not which part of the key differs", got.Why)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a builder of another version builds too, saying so.
|
||||||
|
withVersion(t, "builder-2")
|
||||||
|
r, run, workspace = aModuleAt(t, "tree-one")
|
||||||
|
got, err = BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: named(checked)},
|
||||||
|
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !dockerBuilt(r) || !strings.Contains(got.Why, partBuilder) {
|
||||||
|
t.Fatalf("a newer builder took the older one's build, or did not say why not: %q", got.Why)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No check of the walk's pull requests built it: said.
|
||||||
|
r, run, workspace = aModuleAt(t, "tree-one")
|
||||||
|
got, _ = BuildReusing(t.Context(), run, r, Reuse{Scratch: newScratch(), Pulls: []PullRef{aPull}},
|
||||||
|
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if !dockerBuilt(r) || !strings.Contains(got.Why, "no check of novox/meshboard#7 built it") {
|
||||||
|
t.Fatalf("the walk says %q", got.Why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A copy that fails is said and falls back to a build; it never fails the walk.
|
||||||
|
func TestAFailedCopyOfAChecksBuildFallsBackToBuilding(t *testing.T) {
|
||||||
|
withVersion(t, "builder-1")
|
||||||
|
scratch := newScratch()
|
||||||
|
checked := aCheckBuilt(t, scratch, "tree-one")
|
||||||
|
scratch.failTake = true
|
||||||
|
|
||||||
|
r, run, workspace := aModuleAt(t, "tree-one")
|
||||||
|
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: named(checked)},
|
||||||
|
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a failed copy failed the walk: %v", err)
|
||||||
|
}
|
||||||
|
if !dockerBuilt(r) || got.Reused != nil || len(r.images) == 0 {
|
||||||
|
t.Fatal("a failed copy did not fall back to a build")
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Why, "copying meshboard's") || !strings.Contains(got.Why, "refused the mount") {
|
||||||
|
t.Fatalf("the walk says %q", got.Why)
|
||||||
|
}
|
||||||
|
for repository := range r.images {
|
||||||
|
if strings.HasPrefix(repository, ScratchPrefix) {
|
||||||
|
t.Fatalf("the walk's own build went into the scratch namespace: %s", repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a walk takes a check's build: a plain build — a check's, a dry run's, `build --ref`'s — builds, and a commit
|
||||||
|
// off the trunk never takes one.
|
||||||
|
func TestOnlyAWalkOfATrunkCommitTakesAChecksBuild(t *testing.T) {
|
||||||
|
withVersion(t, "builder-1")
|
||||||
|
scratch := newScratch()
|
||||||
|
checked := aCheckBuilt(t, scratch, "tree-one")
|
||||||
|
|
||||||
|
r, run, workspace := aModuleAt(t, "tree-one")
|
||||||
|
if _, err := Build(t.Context(), run, r, "https://forge.invalid/meshboard.git", "mod", "main", workspace, nil,
|
||||||
|
Npmrc{}, GitCredential{}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !dockerBuilt(r) || len(scratch.took) > 0 {
|
||||||
|
t.Fatal("a build not asked to reuse took a check's build")
|
||||||
|
}
|
||||||
|
|
||||||
|
r, _, workspace = aModuleAt(t, "tree-one") // no trunk said: off it
|
||||||
|
got, err := BuildReusing(t.Context(), r.run, r, Reuse{Scratch: scratch, Named: named(checked)}, "https://forge.invalid/meshboard.git",
|
||||||
|
"mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !dockerBuilt(r) || got.Reused != nil || !strings.Contains(got.Why, "not on its trunk") {
|
||||||
|
t.Fatalf("a commit off the trunk took a check's build: %q", got.Why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A check that did not pass builds nothing for its merge, and one asked to build nothing builds nothing.
|
||||||
|
func TestACheckThatFailedBuildsNothingForItsMerge(t *testing.T) {
|
||||||
|
if skipsTheBuilds(CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "fail"}}) == "" {
|
||||||
|
t.Fatal("a check whose repository layer failed builds for its merge")
|
||||||
|
}
|
||||||
|
if skipsTheBuilds(CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "warning"}}) != "" {
|
||||||
|
t.Fatal("a check that passed with a warning builds nothing for its merge")
|
||||||
|
}
|
||||||
|
r, run, workspace := aModuleAt(t, "tree-one")
|
||||||
|
if built := buildForTheMerge(t.Context(), run, CheckSpec{ID: "build-1"}, CheckBuilding{Publish: r, Scratch: newScratch()},
|
||||||
|
workspace, "", "", CheckVerdict{}, GitCredential{}, nil, logging(nil)); built != nil || len(r.ran) > 0 {
|
||||||
|
t.Fatalf("a check moving no module built %v, ran %v", built, r.ran)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module whose source does not pin its build has no key and is not built by the check; what stands after it
|
||||||
|
// is not built either.
|
||||||
|
func TestACheckBuildsNoModuleWithoutAKeyNorWhatStandsAfterIt(t *testing.T) {
|
||||||
|
withVersion(t, "builder-1")
|
||||||
|
r, run, workspace := aModuleAt(t, "tree-one")
|
||||||
|
r.contents["mod/"+ManifestName] = `{"module":"meshboard","version":"1","build":{"artifacts":[
|
||||||
|
{"name":"mirror","kind":"upstream","from":"docker.io/library/alpine:3"}]}}`
|
||||||
|
spec := CheckSpec{ID: "build-1", Owner: "novox", Repo: "meshboard", Number: 7, Builds: []CheckBuildSpec{
|
||||||
|
{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod", Tier: 0},
|
||||||
|
{Module: "after", Repository: "https://forge.invalid/after.git", Tier: 1}}}
|
||||||
|
built := buildForTheMerge(t.Context(), run, spec, CheckBuilding{Publish: r, Scratch: newScratch()}, workspace, "", "",
|
||||||
|
CheckVerdict{}, GitCredential{}, nil, logging(nil))
|
||||||
|
if len(built) != 2 || !strings.Contains(built[0].NotBuilt, "named by tag") ||
|
||||||
|
!strings.Contains(built[1].NotBuilt, "stands after meshboard") {
|
||||||
|
t.Fatalf("built %+v", built)
|
||||||
|
}
|
||||||
|
if len(r.images) > 0 {
|
||||||
|
t.Fatalf("a module without a key was published: %v", r.images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the key names: every input a fresh build could differ by.
|
||||||
|
func TestTheReuseKeyNamesEveryInputOfTheBuild(t *testing.T) {
|
||||||
|
src := newSourceInputs("m")
|
||||||
|
src.tree = "t1"
|
||||||
|
src.bases = []string{"store:5000/b/x@sha256:" + strings.Repeat("b", 64)}
|
||||||
|
src.contexts["server"] = "ctx1"
|
||||||
|
src.toolchains["tools"] = "go sha256:c 1234"
|
||||||
|
manifest := catalogue.Manifest{Module: "m"}
|
||||||
|
base := keyOf(src, manifest, "v1")
|
||||||
|
if base.Key == "" {
|
||||||
|
t.Fatalf("no key: %s", base.Why)
|
||||||
|
}
|
||||||
|
for name, change := range map[string]func(*sourceInputs) string{
|
||||||
|
partTree: func(s *sourceInputs) string { s.tree = "t2"; return "v1" },
|
||||||
|
partBases: func(s *sourceInputs) string {
|
||||||
|
s.bases = []string{"other@sha256:" + strings.Repeat("c", 64)}
|
||||||
|
return "v1"
|
||||||
|
},
|
||||||
|
partContext: func(s *sourceInputs) string { s.contexts["server"] = "ctx2"; return "v1" },
|
||||||
|
partTool: func(s *sourceInputs) string { s.toolchains["tools"] = "go sha256:d 1234"; return "v1" },
|
||||||
|
partBuilder: func(*sourceInputs) string { return "v2" },
|
||||||
|
} {
|
||||||
|
s := src.copy()
|
||||||
|
version := change(s)
|
||||||
|
if k := keyOf(s, manifest, version); k.Key == base.Key {
|
||||||
|
t.Errorf("a change of %s left the key the same", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A base copied to another address is the same base: the key names it by digest.
|
||||||
|
moved := src.copy()
|
||||||
|
moved.bases = []string{"elsewhere:5000/scratch/b/x@sha256:" + strings.Repeat("b", 64)}
|
||||||
|
if keyOf(moved, manifest, "v1").Key != base.Key {
|
||||||
|
t.Error("the same base at another address changed the key")
|
||||||
|
}
|
||||||
|
if k := keyOf(src, manifest, ""); k.Key != "" {
|
||||||
|
t.Error("a builder whose version cannot be said keyed its build")
|
||||||
|
}
|
||||||
|
unpinned := src.copy()
|
||||||
|
unpinned.notPinned("it resolves packages from the mesh's registry at build time")
|
||||||
|
if k := keyOf(unpinned, manifest, "v1"); k.Key != "" || !strings.Contains(k.Why, "registry") {
|
||||||
|
t.Errorf("a build its source does not pin was keyed: %+v", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aTaggingRegistry is the in-memory registry with tags and their listing, as the store's scratch namespace needs.
|
||||||
|
type aTaggingRegistry struct {
|
||||||
|
*aRegistryOfRepositories
|
||||||
|
tags map[string]map[string]string // repository → tag → manifest digest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *aTaggingRegistry) serve() http.Handler {
|
||||||
|
under := m.handler()
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if repository, ok := strings.CutSuffix(strings.TrimPrefix(r.URL.Path, "/v2/"), "/tags/list"); ok {
|
||||||
|
m.mu.Lock()
|
||||||
|
var tags []string
|
||||||
|
for tag := range m.tags[repository] {
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if tags == nil {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"name": repository, "tags": tags})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repository, kind, rest := splitPath(r.URL.Path)
|
||||||
|
if kind == "blobs" && r.Method == http.MethodHead {
|
||||||
|
m.mu.Lock()
|
||||||
|
body, linked := m.blobs[rest], m.links[repository][rest]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if !linked {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if kind != "manifests" {
|
||||||
|
under.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rest, "sha256:") {
|
||||||
|
m.mu.Lock()
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPut:
|
||||||
|
body, _ := readAll(r)
|
||||||
|
if m.tags[repository] == nil {
|
||||||
|
m.tags[repository] = map[string]string{}
|
||||||
|
}
|
||||||
|
m.tags[repository][rest] = digestOf(body)
|
||||||
|
m.manifests[repository+"@"+digestOf(body)] = body
|
||||||
|
m.mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
digest, ok := m.tags[repository][rest]
|
||||||
|
body, held := m.manifests[repository+"@"+digest]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if !ok || !held {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Docker-Content-Digest", digest)
|
||||||
|
w.Header().Set("Content-Type", mediaTypeOf(body))
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodDelete {
|
||||||
|
m.mu.Lock()
|
||||||
|
for _, tags := range m.tags {
|
||||||
|
for tag, digest := range tags {
|
||||||
|
if digest == rest {
|
||||||
|
delete(tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
under.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A check's builds go after seven days, and when its pull request closes or is merged; the walk's copy in the
|
||||||
|
// module's own namespace stays.
|
||||||
|
func TestCheckBuildsExpireAfterSevenDaysAndGoWhenTheirPullRequestCloses(t *testing.T) {
|
||||||
|
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
|
||||||
|
server := httptest.NewServer(m.serve())
|
||||||
|
defer server.Close()
|
||||||
|
address := strings.TrimPrefix(server.URL, "http://")
|
||||||
|
registry := Registry{Address: address}
|
||||||
|
ctx := t.Context()
|
||||||
|
now := time.Date(2026, 10, 13, 20, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
image := func(repository, body string) string {
|
||||||
|
digest := digestOf([]byte(body))
|
||||||
|
m.manifests[repository+"@"+digest] = []byte(body)
|
||||||
|
return address + "/" + repository + "@" + digest
|
||||||
|
}
|
||||||
|
check := func(pull PullRef, id string, at time.Time, key string) string {
|
||||||
|
reference := image("scratch/meshboard/server", `{"mediaType":"`+mediaManifestOCI+`","layers":[],"n":"`+id+`"}`)
|
||||||
|
build := CheckBuild{Key: key, Module: "meshboard", Made: []catalogue.Built{{Name: "server", Kind: "image",
|
||||||
|
Reference: reference}}, Pull: pull, Check: id, At: at}
|
||||||
|
if err := registry.Keep(ctx, pull, id, at, []CheckBuild{build}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return reference
|
||||||
|
}
|
||||||
|
held := func(reference string) bool {
|
||||||
|
_, digest, _ := strings.Cut(reference, "@")
|
||||||
|
_, ok := m.manifests["scratch/meshboard/server@"+digest]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
|
||||||
|
old := check(aPull, "build-1", now.Add(-8*24*time.Hour), "r1:"+strings.Repeat("1", 64))
|
||||||
|
fresh := check(aPull, "build-2", now.Add(-time.Hour), "r1:"+strings.Repeat("2", 64))
|
||||||
|
others := check(other, "build-3", now.Add(-time.Hour), "r1:"+strings.Repeat("3", 64))
|
||||||
|
walked := image("meshboard/server", `{"mediaType":"`+mediaManifestOCI+`","layers":[],"n":"walk"}`)
|
||||||
|
|
||||||
|
if _, found, err := registry.Find(ctx, "meshboard", "r1:"+strings.Repeat("2", 64)); err != nil || !found {
|
||||||
|
t.Fatalf("a check build kept is not found by its key: %v %v", found, err)
|
||||||
|
}
|
||||||
|
n, err := registry.Expire(ctx, CheckBuildsKept, now)
|
||||||
|
if err != nil || n != 1 {
|
||||||
|
t.Fatalf("expired %d checks (%v), want the one eight days old", n, err)
|
||||||
|
}
|
||||||
|
if held(old) || !held(fresh) || !held(others) {
|
||||||
|
t.Fatalf("after the week's sweep: old %v, fresh %v, another's %v", held(old), held(fresh), held(others))
|
||||||
|
}
|
||||||
|
if _, found, _ := registry.Find(ctx, "meshboard", "r1:"+strings.Repeat("1", 64)); found {
|
||||||
|
t.Fatal("the expired check build's record is still found")
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err = registry.Forget(ctx, aPull)
|
||||||
|
if err != nil || n != 1 {
|
||||||
|
t.Fatalf("forgot %d checks of %s (%v)", n, aPull, err)
|
||||||
|
}
|
||||||
|
if held(fresh) || !held(others) {
|
||||||
|
t.Fatalf("after %s closed: its build held %v, another pull request's %v", aPull, held(fresh), held(others))
|
||||||
|
}
|
||||||
|
if _, ok := m.manifests["meshboard/server@"+strings.SplitN(walked, "@", 2)[1]]; !ok {
|
||||||
|
t.Fatal("letting go of check builds took the walk's copy in the module's namespace")
|
||||||
|
}
|
||||||
|
if left, _ := registry.OfPulls(ctx, []PullRef{aPull}); len(left) != 0 {
|
||||||
|
t.Fatalf("%s still has check builds: %+v", aPull, left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The walk copies a check build by digest: the digest registered is the digest the check built, and the copy is
|
||||||
|
// in the module's own namespace.
|
||||||
|
func TestAChecksBuildIsCopiedByDigestIntoTheModulesNamespace(t *testing.T) {
|
||||||
|
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
|
||||||
|
server := httptest.NewServer(m.serve())
|
||||||
|
defer server.Close()
|
||||||
|
address := strings.TrimPrefix(server.URL, "http://")
|
||||||
|
registry := Registry{Address: address}
|
||||||
|
|
||||||
|
layer := []byte("layer")
|
||||||
|
m.blobs[digestOf(layer)] = layer
|
||||||
|
m.link("scratch/meshboard/server", digestOf(layer))
|
||||||
|
manifest := []byte(`{"mediaType":"` + mediaManifestOCI + `","layers":[{"digest":"` + digestOf(layer) + `","size":5}]}`)
|
||||||
|
m.manifests["scratch/meshboard/server@"+digestOf(manifest)] = manifest
|
||||||
|
archive := []byte("archive")
|
||||||
|
m.blobs[digestOf(archive)] = archive
|
||||||
|
m.link("scratch/meshboard/look", digestOf(archive))
|
||||||
|
|
||||||
|
got, err := registry.Take(t.Context(), "meshboard", catalogue.Built{Name: "server", Kind: "image",
|
||||||
|
Reference: address + "/scratch/meshboard/server@" + digestOf(manifest)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Reference != address+"/meshboard/server@"+digestOf(manifest) {
|
||||||
|
t.Fatalf("copied as %s", got.Reference)
|
||||||
|
}
|
||||||
|
if m.uploads != 0 || m.mounts != 1 {
|
||||||
|
t.Fatalf("the image's layer was moved (%d uploads, %d mounts), not mounted", m.uploads, m.mounts)
|
||||||
|
}
|
||||||
|
got, err = registry.Take(t.Context(), "meshboard", catalogue.Built{Name: "look", Kind: "archive",
|
||||||
|
Digest: digestOf(archive), Reference: "http://" + address + "/v2/scratch/meshboard/look/blobs/" + digestOf(archive)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Digest != digestOf(archive) || got.Reference != "http://"+address+"/v2/meshboard/look/blobs/"+digestOf(archive) ||
|
||||||
|
!m.links["meshboard/look"][digestOf(archive)] {
|
||||||
|
t.Fatalf("the archive was copied as %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two checks that built one digest in one repository share it: letting go of one check's builds keeps it while the
|
||||||
|
// other still names it, and the last to go takes it.
|
||||||
|
func TestABuildTwoChecksShareGoesWithTheLastOfThem(t *testing.T) {
|
||||||
|
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
|
||||||
|
server := httptest.NewServer(m.serve())
|
||||||
|
defer server.Close()
|
||||||
|
address := strings.TrimPrefix(server.URL, "http://")
|
||||||
|
registry := Registry{Address: address}
|
||||||
|
ctx := t.Context()
|
||||||
|
now := time.Date(2026, 10, 13, 20, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
body := `{"mediaType":"` + mediaManifestOCI + `","layers":[]}`
|
||||||
|
digest := digestOf([]byte(body))
|
||||||
|
m.manifests["scratch/meshboard/server@"+digest] = []byte(body)
|
||||||
|
reference := address + "/scratch/meshboard/server@" + digest
|
||||||
|
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
|
||||||
|
for i, pull := range []PullRef{aPull, other} {
|
||||||
|
id := "build-" + strconv.Itoa(i+1)
|
||||||
|
b := CheckBuild{Key: "r1:" + strings.Repeat(strconv.Itoa(i+1), 64), Module: "meshboard", Pull: pull, Check: id,
|
||||||
|
At: now, Made: []catalogue.Built{{Name: "server", Kind: "image", Reference: reference}}}
|
||||||
|
if err := registry.Keep(ctx, pull, id, now, []CheckBuild{b}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
held := func() bool { _, ok := m.manifests["scratch/meshboard/server@"+digest]; return ok }
|
||||||
|
|
||||||
|
if n, err := registry.Forget(ctx, aPull); err != nil || n != 1 {
|
||||||
|
t.Fatalf("forgot %d (%v)", n, err)
|
||||||
|
}
|
||||||
|
if !held() {
|
||||||
|
t.Fatal("letting go of one check's builds took a build another check still names")
|
||||||
|
}
|
||||||
|
if n, err := registry.Forget(ctx, other); err != nil || n != 1 {
|
||||||
|
t.Fatalf("forgot %d (%v)", n, err)
|
||||||
|
}
|
||||||
|
if held() {
|
||||||
|
t.Fatal("the build stayed after the last check naming it went")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,12 +58,55 @@ type sourceInputs struct {
|
|||||||
// whole when an artifact's is not known (novox/hq ADR 0267).
|
// whole when an artifact's is not known (novox/hq ADR 0267).
|
||||||
own map[string]bool
|
own map[string]bool
|
||||||
ownIsAll bool
|
ownIsAll bool
|
||||||
|
// root is the module's build source in its own repository outside its directory, relative to the
|
||||||
|
// repository's root: the list of predefined keys a manifest naming one was judged against (ADR 0348 §7).
|
||||||
|
root map[string]bool
|
||||||
// read is, per context (repository and ref), the build source read there; nil for one read whole.
|
// read is, per context (repository and ref), the build source read there; nil for one read whole.
|
||||||
read map[string]map[string]bool
|
read map[string]map[string]bool
|
||||||
readAs map[string]catalogue.ArtifactContext
|
readAs map[string]catalogue.ArtifactContext
|
||||||
// contextsAtHead is false once a context was cloned at something other than its trunk's head: its
|
// contextsAtHead is false once a context was cloned at something other than its trunk's head: its
|
||||||
// closure is not the one the next merge there meets, and the build says no build source.
|
// closure is not the one the next merge there meets, and the build says no build source.
|
||||||
contextsAtHead bool
|
contextsAtHead bool
|
||||||
|
// onlyKey says the inputs are being taken for a reuse key (novox/hq ADR 0350 §2): each artifact goes as far
|
||||||
|
// as what its source fingerprint reads, and nothing is built or published.
|
||||||
|
onlyKey bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyOnly is whether these inputs are only being taken for a reuse key.
|
||||||
|
func (s *sourceInputs) keyOnly() bool { return s != nil && s.onlyKey }
|
||||||
|
|
||||||
|
// copy is the inputs taken so far, apart: a walk's key is taken from them without touching the build's own.
|
||||||
|
func (s *sourceInputs) copy() *sourceInputs {
|
||||||
|
c := newSourceInputs(s.module)
|
||||||
|
c.tree, c.unpinned, c.prefix, c.ownIsAll, c.contextsAtHead = s.tree, s.unpinned, s.prefix, s.ownIsAll, s.contextsAtHead
|
||||||
|
c.bases = append([]string{}, s.bases...)
|
||||||
|
for k, v := range s.contexts {
|
||||||
|
c.contexts[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range s.toolchains {
|
||||||
|
c.toolchains[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range s.own {
|
||||||
|
c.own[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range s.root {
|
||||||
|
c.root[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range s.read {
|
||||||
|
if v == nil {
|
||||||
|
c.read[k] = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
set := map[string]bool{}
|
||||||
|
for e := range v {
|
||||||
|
set[e] = true
|
||||||
|
}
|
||||||
|
c.read[k] = set
|
||||||
|
}
|
||||||
|
for k, v := range s.readAs {
|
||||||
|
c.readAs[k] = v
|
||||||
|
}
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// contextOffHead says a context was not its trunk's head.
|
// contextOffHead says a context was not its trunk's head.
|
||||||
@@ -75,7 +118,7 @@ func (s *sourceInputs) contextOffHead() {
|
|||||||
|
|
||||||
func newSourceInputs(module string) *sourceInputs {
|
func newSourceInputs(module string) *sourceInputs {
|
||||||
return &sourceInputs{module: module, contexts: map[string]string{}, toolchains: map[string]string{},
|
return &sourceInputs{module: module, contexts: map[string]string{}, toolchains: map[string]string{},
|
||||||
own: map[string]bool{}, read: map[string]map[string]bool{}, readAs: map[string]catalogue.ArtifactContext{},
|
own: map[string]bool{}, root: map[string]bool{}, read: map[string]map[string]bool{}, readAs: map[string]catalogue.ArtifactContext{},
|
||||||
contextsAtHead: true}
|
contextsAtHead: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +132,16 @@ func (s *sourceInputs) ownHas(entries ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rootHas adds entries, relative to the repository's root, to the module's build source in its own repository.
|
||||||
|
func (s *sourceInputs) rootHas(entries ...string) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
s.root[e] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ownWhole says an artifact's build source in the module's own repository is not known: the module's
|
// ownWhole says an artifact's build source in the module's own repository is not known: the module's
|
||||||
// whole directory is its source, as it was before.
|
// whole directory is its source, as it was before.
|
||||||
func (s *sourceInputs) ownWhole() {
|
func (s *sourceInputs) ownWhole() {
|
||||||
@@ -141,6 +194,9 @@ func (s *sourceInputs) buildSources() []BuildSource {
|
|||||||
for e := range s.own {
|
for e := range s.own {
|
||||||
own = append(own, withPrefix(s.prefix, e))
|
own = append(own, withPrefix(s.prefix, e))
|
||||||
}
|
}
|
||||||
|
for e := range s.root {
|
||||||
|
own = append(own, e)
|
||||||
|
}
|
||||||
sort.Strings(own)
|
sort.Strings(own)
|
||||||
out = append(out, BuildSource{Paths: compactSorted(own)})
|
out = append(out, BuildSource{Paths: compactSorted(own)})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withTemplateTexts is what was built with each template's text, for a module whose files name templates
|
||||||
|
// (novox/hq ADR 0349 §2): its templates archive gains the text of every file in it, read back from the packed
|
||||||
|
// bytes, by its path inside the archive. Resolve reads them into the files' content.
|
||||||
|
//
|
||||||
|
// **Read from the archive, proven by its digest.** The module's files/ is packed again, as the build packs it,
|
||||||
|
// and the bytes are read only when they hash to the digest the build made or took: a fresh build's own archive,
|
||||||
|
// or a check's build a walk copied by digest without building (novox/hq ADR 0350 §2). Packing is deterministic,
|
||||||
|
// so one source packs to one digest; a tree that packs to another is not the archive, and its text is refused.
|
||||||
|
func withTemplateTexts(manifest catalogue.Manifest, within string, built []catalogue.Built) ([]catalogue.Built, error) {
|
||||||
|
if !manifest.Templated() {
|
||||||
|
return built, nil
|
||||||
|
}
|
||||||
|
name := catalogue.TemplatesArtifact(manifest)
|
||||||
|
at := -1
|
||||||
|
for i, b := range built {
|
||||||
|
if b.Name == name {
|
||||||
|
at = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" || at < 0 {
|
||||||
|
return nil, fmt.Errorf("%s names templates and built no archive of %s/ to read them from",
|
||||||
|
manifest.Module, catalogue.TemplatesDir)
|
||||||
|
}
|
||||||
|
body, err := pack(filepath.Join(within, catalogue.TemplatesDir))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: packing %s/ to read its templates back failed: %w", manifest.Module,
|
||||||
|
catalogue.TemplatesDir, err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(body)
|
||||||
|
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||||
|
if built[at].Digest != digest {
|
||||||
|
return nil, fmt.Errorf("%s: %s/ packs to the digest %s, and the archive %q built or taken is %s: the "+
|
||||||
|
"templates are read only from the archive the mesh records", manifest.Module, catalogue.TemplatesDir,
|
||||||
|
digest, name, orNone(built[at].Digest))
|
||||||
|
}
|
||||||
|
texts, err := unpackTexts(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: reading the archive %q back failed: %w", manifest.Module, name, err)
|
||||||
|
}
|
||||||
|
out := append([]catalogue.Built(nil), built...)
|
||||||
|
out[at].Texts = texts
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unpackTexts is every file of a packed archive (pack), by its path inside it.
|
||||||
|
func unpackTexts(body []byte) (map[string]string, error) {
|
||||||
|
zipped, err := gzip.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer zipped.Close()
|
||||||
|
reader := tar.NewReader(zipped)
|
||||||
|
texts := map[string]string{}
|
||||||
|
for {
|
||||||
|
header, err := reader.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return texts, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if header.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
texts[header.Name] = string(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orNone(digest string) string {
|
||||||
|
if digest == "" {
|
||||||
|
return "no digest"
|
||||||
|
}
|
||||||
|
return digest
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module's files kept as template files in its repository (novox/hq ADR 0349 §2, design 57 package T1, issue
|
||||||
|
// 654): the build packs files/ into the module's templates archive and reads each template's text back from the
|
||||||
|
// packed bytes into the file's content, so the manifest the mesh holds is the one it would hold for the text inline.
|
||||||
|
|
||||||
|
const templatedModule = `{"module":"meshboard","version":"1",
|
||||||
|
"build":{"artifacts":[
|
||||||
|
{"name":"server","kind":"image","from":"Dockerfile"},
|
||||||
|
{"name":"files","kind":"archive","from":"files"}]},
|
||||||
|
"resources":[
|
||||||
|
{"id":"svc","type":"container","name":"meshboard","artifact":"server"},
|
||||||
|
{"id":"conf","type":"file","path":"/etc/meshboard/meshboard.conf","template":"files/meshboard.conf"},
|
||||||
|
{"id":"deeper","type":"file","path":"/etc/meshboard/deeper.conf","template":"files/a/deeper.conf"}]}`
|
||||||
|
|
||||||
|
var templatedFiles = map[string]string{
|
||||||
|
"Dockerfile": "FROM scratch", "files/meshboard.conf": "listen = 8080\n\"quoted\" é\n",
|
||||||
|
"files/a/deeper.conf": "deeper\n", "files/unused.conf": "packed, named by nothing",
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentOf(t *testing.T, m catalogue.Manifest, id string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["id"] == id {
|
||||||
|
if _, has := r[catalogue.TemplateField]; has {
|
||||||
|
t.Fatalf("%s still names its template in the built manifest: %v", id, r)
|
||||||
|
}
|
||||||
|
text, ok := r["content"].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s carries no content: %v", id, r)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no resource %s in %v", id, m.Resources)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildReadsEachTemplateBackFromItsArchive(t *testing.T) {
|
||||||
|
r, workspace := aRepository(t, templatedModule, templatedFiles)
|
||||||
|
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil,
|
||||||
|
Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if text := contentOf(t, got.Manifest, "conf"); text != templatedFiles["files/meshboard.conf"] {
|
||||||
|
t.Fatalf("the template's text is %q, not the file's", text)
|
||||||
|
}
|
||||||
|
if text := contentOf(t, got.Manifest, "deeper"); text != templatedFiles["files/a/deeper.conf"] {
|
||||||
|
t.Fatalf("the template's text is %q, not the file's", text)
|
||||||
|
}
|
||||||
|
if r.archives["meshboard/files"] == "" {
|
||||||
|
t.Fatalf("the templates archive was not published: %v", r.archives)
|
||||||
|
}
|
||||||
|
if problem := catalogue.HeldTemplateProblem(got.Manifest); problem != "" {
|
||||||
|
t.Fatal(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildWhoseTemplateIsMissingFailsNamingIt(t *testing.T) {
|
||||||
|
files := map[string]string{}
|
||||||
|
for k, v := range templatedFiles {
|
||||||
|
files[k] = v
|
||||||
|
}
|
||||||
|
delete(files, "files/a/deeper.conf")
|
||||||
|
r, workspace := aRepository(t, templatedModule, files)
|
||||||
|
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil,
|
||||||
|
Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "files/a/deeper.conf") {
|
||||||
|
t.Fatalf("a build whose template is missing did not fail naming it: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A walk that takes its check's build copies the archive by digest and builds nothing: the templates are read back
|
||||||
|
// from the tree packed again, which is the archive only when it packs to the digest taken.
|
||||||
|
func TestAWalkTakingAChecksBuildReadsTheTemplatesBackToo(t *testing.T) {
|
||||||
|
withVersion(t, "builder-1")
|
||||||
|
contents := map[string]string{"mod/" + ManifestName: templatedModule}
|
||||||
|
for k, v := range templatedFiles {
|
||||||
|
contents["mod/"+k] = v
|
||||||
|
}
|
||||||
|
scratch := newScratch()
|
||||||
|
r, run, workspace := aModuleWith(t, "tree-one", contents)
|
||||||
|
checked := aCheckBuiltBy(t, scratch, r, run, workspace)
|
||||||
|
|
||||||
|
r, run, workspace = aModuleWith(t, "tree-one", contents)
|
||||||
|
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: named(checked)},
|
||||||
|
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Reused == nil || len(r.archives) > 0 {
|
||||||
|
t.Fatalf("the walk built again: reused %+v, why %q, published %v", got.Reused, got.Why, r.archives)
|
||||||
|
}
|
||||||
|
if text := contentOf(t, got.Manifest, "conf"); text != templatedFiles["files/meshboard.conf"] {
|
||||||
|
t.Fatalf("the template's text is %q, not the file's", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tree packed again must be the archive taken; text read from anything else is not the archive's.
|
||||||
|
func TestTemplateTextsAreRefusedWhenTheTreeDoesNotPackToTheDigestTaken(t *testing.T) {
|
||||||
|
m, err := catalogue.ParseManifest([]byte(templatedModule))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
within := writeTree(t, t.TempDir(), templatedFiles)
|
||||||
|
built := []catalogue.Built{{Name: "files", Kind: catalogue.ArtifactArchive, Digest: "sha256:" + strings.Repeat("0", 64)}}
|
||||||
|
if _, err := withTemplateTexts(m, within, built); err == nil || !strings.Contains(err.Error(), "digest") {
|
||||||
|
t.Fatalf("text was read from a tree that does not pack to the archive taken: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTree writes files under dir and answers dir.
|
||||||
|
func writeTree(t *testing.T, dir string, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
for name, body := range files {
|
||||||
|
full := filepath.Join(dir, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
@@ -87,6 +87,11 @@ func Openings(rules []Rule, foundation []int, published map[string]map[int]int)
|
|||||||
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
|
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
|
||||||
case FromMesh:
|
case FromMesh:
|
||||||
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
|
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
|
||||||
|
case FromLocalNetwork:
|
||||||
|
// The private network's part only (novox/hq ADR 0340). An adopted node's found firewall
|
||||||
|
// stays, and it is what admits the machine's local network; an opening has no word for a
|
||||||
|
// local network, and widening it to everywhere would open the port to the internet.
|
||||||
|
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, port := range foundation {
|
for _, port := range foundation {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user