Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a111e70a | ||
|
|
eb4f16431c | ||
|
|
c4fd31aa7e | ||
|
|
09d5360bce | ||
|
|
b4d7a32abf | ||
|
|
6aa4f135c9 | ||
|
|
4915f69857 | ||
|
|
843e408556 | ||
|
|
4ef2fa2dff | ||
|
|
8288042eb7 | ||
|
|
3bbae41037 | ||
|
|
77f3963d83 | ||
|
|
49a28f8537 | ||
|
|
c770f7947d | ||
|
|
4b8e7f11cf | ||
|
|
0d2686b5eb | ||
|
|
839be0eb64 | ||
|
|
dc144a4e7d | ||
|
|
bfc42e4f4f | ||
|
|
c4176107f2 | ||
|
|
a3794ea156 | ||
|
|
e8cb2ee470 | ||
|
|
07b3c5e77a | ||
|
|
f3100d1116 | ||
|
|
64a96a13f7 | ||
|
|
f1fb73e12c | ||
|
|
d2faada61b | ||
|
|
f436d1dace | ||
|
|
12ff1cfbc4 | ||
|
|
7bf0de0ff5 | ||
|
|
59b13d091d | ||
|
|
ce72a366bf | ||
|
|
d9876e5296 | ||
|
|
62e67578eb | ||
|
|
1319432e55 | ||
|
|
0cf7b2344f | ||
|
|
aa5d263c08 | ||
|
|
769a8d6f6a | ||
|
|
34b310b9f8 | ||
|
|
33d8b71cf7 | ||
|
|
aa0eaa6c52 | ||
|
|
d5804927b3 | ||
|
|
3793175894 | ||
|
|
b70f246622 | ||
|
|
cdbdb47474 | ||
|
|
689e61081d | ||
|
|
c83f48b821 | ||
|
|
db5e6c5b48 | ||
|
|
b9dcf19c3f | ||
|
|
e3a714d5f0 | ||
|
|
08cec8cb1c | ||
|
|
e4cbffbaa9 | ||
|
|
f178aab641 |
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
// Every call a hook prints for a session to copy must be accepted by claude_code_wait's own argument check
|
||||
// (issue 1757): the Stop hook's suggested watched and said calls, and the SessionStart hook's suggested
|
||||
// adopt call.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// suggestedCall extracts the one JSON object embedded in a hook's message, right after "claude_code_wait "
|
||||
// — what a session is expected to copy verbatim — and parses it into the arguments a tool call takes.
|
||||
func suggestedCall(t *testing.T, msg string) map[string]any {
|
||||
t.Helper()
|
||||
i := strings.Index(msg, "claude_code_wait ")
|
||||
if i < 0 {
|
||||
t.Fatalf("no suggested call in: %s", msg)
|
||||
}
|
||||
rest := msg[i+len("claude_code_wait "):]
|
||||
start := strings.Index(rest, "{")
|
||||
if start < 0 {
|
||||
t.Fatalf("no JSON object in: %s", msg)
|
||||
}
|
||||
depth, end := 0, -1
|
||||
for j, c := range rest[start:] {
|
||||
switch c {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = start + j + 1
|
||||
}
|
||||
}
|
||||
if end >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
t.Fatalf("unbalanced JSON object in: %s", msg)
|
||||
}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(rest[start:end]), &args); err != nil {
|
||||
t.Fatalf("the suggested call is not valid JSON: %v (%s)", err, rest[start:end])
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
|
||||
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
|
||||
if err != nil || len(f.Waits) != 1 {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], t0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
|
||||
t.Fatalf("the suggested call does not carry by: %s", reason)
|
||||
}
|
||||
args := suggestedCall(t, reason)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested watched call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
|
||||
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
clock := t0
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
clock = t0.Add(2 * time.Minute) // past its 1-minute bound, for the live tool call too
|
||||
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
|
||||
if err != nil || len(f.Waits) != 1 {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], clock)
|
||||
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
|
||||
t.Fatalf("the suggested call does not carry by: %s", reason)
|
||||
}
|
||||
args := suggestedCall(t, reason)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested said call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1757: the SessionStart hook's suggested adopt call already carries `by` — checked, as asked, so a
|
||||
// regression here is caught the same way as the Stop hook's.
|
||||
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := newMemWaits()
|
||||
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
|
||||
t.Cleanup(func() { waiting = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
|
||||
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@g14",
|
||||
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
|
||||
if isErr {
|
||||
t.Fatalf("add: %s", text)
|
||||
}
|
||||
|
||||
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
|
||||
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
|
||||
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
|
||||
}
|
||||
args := suggestedCall(t, msg)
|
||||
|
||||
text, isErr = s.call(t, "claude_code_wait", args)
|
||||
if isErr {
|
||||
t.Fatalf("the suggested adopt call, as printed, was refused: %s", text)
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,12 @@ func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is open and nothing follows it yet. Read where it stands with %s, then "+
|
||||
"%s, and mark it watched: claude_code_wait "+
|
||||
"{\"op\":\"watched\",\"session\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address, watcherAdvice(w),
|
||||
session, w.ID)
|
||||
"{\"op\":\"watched\",\"session\":%q,\"by\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address,
|
||||
watcherAdvice(w), session, w.By, w.ID)
|
||||
}
|
||||
return true, fmt.Sprintf("wait %s (%s) is past its bound and the operator has not been told. Tell the operator what "+
|
||||
"waits, since when, and what you are doing about it, then mark it: claude_code_wait "+
|
||||
"{\"op\":\"said\",\"session\":%q,\"id\":%q}.", w.ID, w.What, session, w.ID)
|
||||
"{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID)
|
||||
}
|
||||
|
||||
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
|
||||
@@ -113,15 +113,34 @@ func readRefusalCounters(waitsDir, session string) map[string]int {
|
||||
return m
|
||||
}
|
||||
|
||||
// writeRefusalCounters writes the counter file beside the waits file. Its caller decides what an error here
|
||||
// means: harmless when the stop is already allowed, but when the stop was about to be refused, a cap that
|
||||
// cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1 item 2).
|
||||
// writeRefusalCounters keeps the counter file beside the waits file in step with m, through fsys like every
|
||||
// other write into the agent's home (novox/hq ADR 0266, issue 1749): called from the Stop hook, which
|
||||
// already runs as the agent's own account (fsys is then its zero value, a plain write or remove, as
|
||||
// before), and from the tool's AdoptName, which runs as the operator's account and must reach a confined
|
||||
// node's agent home through the sudo helper fsys carries such a write through. Its caller decides what an
|
||||
// error here means: harmless when the stop is already allowed, but when the stop was about to be refused, a
|
||||
// cap that cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1
|
||||
// item 2).
|
||||
//
|
||||
// Empty, m is removed rather than written as "{}" (issue 1749, follow-up): a stop with nothing to record —
|
||||
// every stop of every session with no waits, the ordinary case — must leave neither the file nor, the first
|
||||
// time, the waits folder itself; removing a file that was never there is not an error.
|
||||
func writeRefusalCounters(waitsDir, session string, m map[string]int) error {
|
||||
path := refusalCountersPath(waitsDir, session)
|
||||
if len(m) == 0 {
|
||||
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(refusalCountersPath(waitsDir, session), raw, 0o600)
|
||||
if err := fsys.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsys.WriteFile(path, raw, 0o600)
|
||||
}
|
||||
|
||||
// notice says a fail-open or let-through reason on both the log (stderr) and the screen: Claude Code shows a
|
||||
|
||||
@@ -170,13 +170,72 @@ func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "unwatched") {
|
||||
t.Fatalf("the let-through notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-6"))
|
||||
// Once let through, the counters are empty again: the file is removed, not left holding "{}" (issue
|
||||
// 1749, follow-up).
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-6")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the counter file was not removed once its counters emptied: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: a stop for a session with no waits at all leaves nothing behind — not the counter
|
||||
// file, not even the waits folder a node that never added a wait would otherwise gain on every stop of
|
||||
// every session.
|
||||
func TestStopWithNoWaitsLeavesNoFolderAndNoFile(t *testing.T) {
|
||||
waitsDir := filepath.Join(t.TempDir(), "waits") // not created: this is what the hook is handed cold
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-none","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(waitsDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("a stop with no waits created the waits folder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: a refusal writes the counter file (there is something to record).
|
||||
func TestStopWithARefusalWritesTheCounterFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
f := WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}}
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"), f)
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-7"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var counters map[string]int
|
||||
if json.Unmarshal(raw, &counters) != nil || len(counters) != 0 {
|
||||
t.Fatalf("the counter file was not reset: %s", raw)
|
||||
if json.Unmarshal(raw, &counters) != nil || counters["w-7"] != 1 {
|
||||
t.Fatalf("the counter file: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749, follow-up: once the wait is done (no longer in the waits file), the next stop removes the
|
||||
// counter file rather than leaving a stale "{}" behind.
|
||||
func TestStopAfterTheWaitIsDoneRemovesTheCounterFile(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
path := filepath.Join(waitsDir, "sess-8.json")
|
||||
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{openWait("w-8", 10)}})
|
||||
stdin := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("refusal: code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); err != nil {
|
||||
t.Fatalf("the counter file was not written: %v", err)
|
||||
}
|
||||
|
||||
// The wait is done: it is no longer in the waits file (DoneWait removes it, waits.go).
|
||||
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{}})
|
||||
var stdout2, stderr2 bytes.Buffer
|
||||
stdin2 := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin2, &stdout2, &stderr2); code != 0 {
|
||||
t.Fatalf("after done: code %d, stderr %s", code, stderr2.String())
|
||||
}
|
||||
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the counter file was not removed after the wait was done: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,3 +332,31 @@ func TestHookRegisterStillRefusesEveryHook(t *testing.T) {
|
||||
t.Fatalf("a Stop hook was not refused: %v %s", isError, text)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1749: adopt's refusal-counter reset writes into the agent's home (beside the waits file) and must
|
||||
// go through fsys like every other such write (novox/hq ADR 0266) — on a confined node the module runs as
|
||||
// the operator's account, which cannot write there directly; only the helper, as the agent's own account,
|
||||
// can. The confined-node fake (agentFiles, agent_account_test.go) proves it: a write that reaches the real
|
||||
// filesystem only through the helper's request/response protocol bumps its count.
|
||||
func TestWriteRefusalCountersGoesThroughTheHelper(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
was := fsys
|
||||
fsys = f
|
||||
t.Cleanup(func() { fsys = was })
|
||||
|
||||
waitsDir := filepath.Join(home, ".claude", "nox-mesh", "waits")
|
||||
if err := writeRefusalCounters(waitsDir, "sess-1", map[string]int{"w-1": 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counted.asked.Load() == 0 {
|
||||
t.Fatal("the counter file was written directly, never through the helper")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(waitsDir, "sess-1.refusals.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]int
|
||||
if json.Unmarshal(raw, &m) != nil || m["w-1"] != 2 {
|
||||
t.Fatalf("the written counters: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ machine's resources a seat, these tools become its verbs.
|
||||
|
||||
## Tools
|
||||
|
||||
All five only read.
|
||||
All six only read.
|
||||
|
||||
| tool | what | replaces |
|
||||
|---|---|---|
|
||||
@@ -32,6 +32,7 @@ All five only read.
|
||||
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
|
||||
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
|
||||
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
|
||||
| `disk_folders` | Which folders fill a disk, like `du -x`: the largest folders under `path` (default `/`) to `depth` levels (default 2, at most 3), `limit` of them (default 20, at most 100), with bytes and GiB. Sizes are allocated bytes, a hard link once. It stays on the path's one filesystem: a mount below it is named, never entered. It reads every folder the tool runner can read (docker's included) and no content; folders it cannot read are counted and named. After 25 s (the tool runner gives a call 30 s) it answers what it has measured, marked `partial`, with `stopped_at` (novox/hq issue 1804) | `du -x`, `ncdu` |
|
||||
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
|
||||
|
||||
### How each number is read
|
||||
@@ -87,4 +88,5 @@ cover:
|
||||
- processes ranked, one that started inside the window left out, unreadable ones counted;
|
||||
- filesystems said once, mount escapes undone, a statfs that fails said;
|
||||
- the window's bounds;
|
||||
- folders ranked biggest first to the depth asked, a mount never entered, unreadable folders named, a walk past its bound answered as partial with where it stopped, a hard link counted once;
|
||||
- the manifest: tools listed equal tools served, all read-only, no resources, no machine named.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FolderBudget is how long a folder walk may take before it answers what it has measured.
|
||||
const FolderBudget = 25 * time.Second
|
||||
|
||||
// namedUnreadable is how many unreadable folders are named; the rest are only counted.
|
||||
const namedUnreadable = 50
|
||||
|
||||
// Folder is one folder's size: everything under it on the same filesystem.
|
||||
type Folder struct {
|
||||
Path string `json:"path"`
|
||||
Depth int `json:"depth"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
GiB float64 `json:"gib"`
|
||||
}
|
||||
|
||||
// FolderReport is the answer of disk_folders.
|
||||
type FolderReport struct {
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
GiB float64 `json:"gib"`
|
||||
Largest []Folder `json:"largest"`
|
||||
Partial bool `json:"partial"`
|
||||
StoppedAt string `json:"stopped_at,omitempty"`
|
||||
Unreadable int `json:"unreadable"`
|
||||
UnreadableFolders []string `json:"unreadable_folders,omitempty"`
|
||||
Skipped []string `json:"other_filesystems_skipped,omitempty"`
|
||||
Said []string `json:"said"`
|
||||
}
|
||||
|
||||
func (m *Machine) readDir(real string) ([]os.DirEntry, error) {
|
||||
if m.ReadDir != nil {
|
||||
return m.ReadDir(real)
|
||||
}
|
||||
return os.ReadDir(real)
|
||||
}
|
||||
|
||||
func (m *Machine) now() time.Time {
|
||||
if m.Clock != nil {
|
||||
return m.Clock()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func devOf(path string, info fs.FileInfo) uint64 {
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
return uint64(st.Dev)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type inodeKey struct{ dev, ino uint64 }
|
||||
|
||||
// walk is one folder measurement.
|
||||
type walk struct {
|
||||
m *Machine
|
||||
maxDepth int
|
||||
dev uint64
|
||||
deadline time.Time
|
||||
seen map[inodeKey]bool
|
||||
folders []Folder
|
||||
bad []string
|
||||
skipped []string
|
||||
stopped string
|
||||
}
|
||||
|
||||
// Folders measures path like du -x: allocated bytes, a hard link once, never entering another filesystem.
|
||||
func (m *Machine) Folders(path string, depth, limit int) (FolderReport, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return FolderReport{}, fmt.Errorf("path must be absolute, not %q", path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
info, err := os.Stat(m.path(path))
|
||||
if err != nil {
|
||||
return FolderReport{}, fmt.Errorf("path %s: %w", path, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return FolderReport{}, fmt.Errorf("path %s is not a folder", path)
|
||||
}
|
||||
budget := m.Budget
|
||||
if budget == 0 {
|
||||
budget = FolderBudget
|
||||
}
|
||||
w := &walk{m: m, maxDepth: depth, deadline: m.now().Add(budget), seen: map[inodeKey]bool{}}
|
||||
w.dev = w.device(path, info)
|
||||
total := w.dir(path, 0)
|
||||
|
||||
sort.SliceStable(w.folders, func(i, j int) bool {
|
||||
if w.folders[i].Bytes != w.folders[j].Bytes {
|
||||
return w.folders[i].Bytes > w.folders[j].Bytes
|
||||
}
|
||||
return w.folders[i].Path < w.folders[j].Path
|
||||
})
|
||||
if len(w.folders) > limit {
|
||||
w.folders = w.folders[:limit]
|
||||
}
|
||||
sort.Strings(w.bad)
|
||||
named := w.bad
|
||||
if len(named) > namedUnreadable {
|
||||
named = named[:namedUnreadable]
|
||||
}
|
||||
r := FolderReport{
|
||||
Path: path, Bytes: total, GiB: gibOf(total), Largest: w.folders,
|
||||
Partial: w.stopped != "", StoppedAt: w.stopped,
|
||||
Unreadable: len(w.bad), UnreadableFolders: named, Skipped: w.skipped,
|
||||
}
|
||||
if r.Largest == nil {
|
||||
r.Largest = []Folder{}
|
||||
}
|
||||
var said []string
|
||||
if r.Partial {
|
||||
said = append(said, fmt.Sprintf("PARTIAL: the walk stopped after %s at %s; sizes are what was measured so far", budget, r.StoppedAt))
|
||||
}
|
||||
said = append(said, fmt.Sprintf("%s holds %.1f GiB on its own filesystem", path, r.GiB))
|
||||
if r.Unreadable > 0 {
|
||||
said = append(said, fmt.Sprintf("%d folders could not be read (%s), so their size is missing", r.Unreadable, strings.Join(named, ", ")))
|
||||
}
|
||||
if len(r.Skipped) > 0 {
|
||||
said = append(said, "other filesystems not entered: "+strings.Join(r.Skipped, ", "))
|
||||
}
|
||||
r.Said = said
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (w *walk) device(path string, info fs.FileInfo) uint64 {
|
||||
if w.m.DevOf != nil {
|
||||
return w.m.DevOf(path, info)
|
||||
}
|
||||
return devOf(path, info)
|
||||
}
|
||||
|
||||
// dir is the allocated bytes under a folder; it records the folder when it is within the depth.
|
||||
func (w *walk) dir(path string, depth int) int64 {
|
||||
if w.stopped != "" {
|
||||
return 0
|
||||
}
|
||||
if w.m.now().After(w.deadline) {
|
||||
w.stopped = path
|
||||
return 0
|
||||
}
|
||||
entries, err := w.m.readDir(w.m.path(path))
|
||||
if err != nil {
|
||||
w.bad = append(w.bad, path)
|
||||
return 0
|
||||
}
|
||||
var total int64
|
||||
for _, e := range entries {
|
||||
child := filepath.Join(path, e.Name())
|
||||
info, err := os.Lstat(w.m.path(child))
|
||||
if err != nil {
|
||||
continue // gone since it was listed
|
||||
}
|
||||
if info.IsDir() {
|
||||
if w.device(child, info) != w.dev {
|
||||
w.skipped = append(w.skipped, child)
|
||||
continue
|
||||
}
|
||||
total += w.allocated(info, false) + w.dir(child, depth+1)
|
||||
continue
|
||||
}
|
||||
total += w.allocated(info, true)
|
||||
}
|
||||
if depth >= 1 && depth <= w.maxDepth {
|
||||
w.folders = append(w.folders, Folder{Path: path, Depth: depth, Bytes: total, GiB: gibOf(total)})
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// allocated is the bytes an entry occupies on disk, as du counts them; a file with several links once.
|
||||
func (w *walk) allocated(info fs.FileInfo, file bool) int64 {
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return info.Size()
|
||||
}
|
||||
if file && st.Nlink > 1 {
|
||||
k := inodeKey{uint64(st.Dev), uint64(st.Ino)}
|
||||
if w.seen[k] {
|
||||
return 0
|
||||
}
|
||||
w.seen[k] = true
|
||||
}
|
||||
return st.Blocks * 512
|
||||
}
|
||||
|
||||
func gibOf(b int64) float64 { return math.Round(float64(b)/gib*100) / 100 }
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mb = 1 << 20
|
||||
|
||||
// megabytes writes a file of n MiB under the fake root.
|
||||
func (f *fake) megabytes(path string, n int) { f.file(path, strings.Repeat("x", n*mb)) }
|
||||
|
||||
func aFullDisk(t *testing.T) *fake {
|
||||
f := newFake(t)
|
||||
f.megabytes("var/lib/docker/overlay2/a/layer", 8)
|
||||
f.megabytes("var/lib/docker/overlay2/b/layer", 4)
|
||||
f.megabytes("var/log/big.log", 3)
|
||||
f.megabytes("home/jo/video.mkv", 6)
|
||||
f.megabytes("home/jo/deep/er/est/file", 1)
|
||||
f.dir("empty")
|
||||
return f
|
||||
}
|
||||
|
||||
func folders(t *testing.T, m *Machine, args map[string]any) FolderReport {
|
||||
t.Helper()
|
||||
out, err := tool(t, m, "disk_folders").Run(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.(FolderReport)
|
||||
}
|
||||
|
||||
func paths(r FolderReport) string {
|
||||
var p []string
|
||||
for _, f := range r.Largest {
|
||||
p = append(p, f.Path)
|
||||
}
|
||||
return strings.Join(p, ",")
|
||||
}
|
||||
|
||||
// 1804/AC1: given a disk with folders of known sizes, when disk_folders runs with the defaults, then it answers
|
||||
// the largest folders to depth 2, biggest first, with bytes and GiB, and the total of the path.
|
||||
func TestFoldersAreRankedBiggestFirstToDepthTwoByDefault(t *testing.T) {
|
||||
r := folders(t, aFullDisk(t).machine(), nil)
|
||||
if got := paths(r); !strings.HasPrefix(got, "/var,/var/lib,") || !strings.Contains(got, "/home,/home/jo") {
|
||||
t.Fatalf("ranked %s", got)
|
||||
}
|
||||
if r.Path != "/" || r.Partial || r.Unreadable != 0 {
|
||||
t.Errorf("%+v", r)
|
||||
}
|
||||
top := r.Largest[0]
|
||||
if top.Path != "/var" || top.Depth != 1 || top.Bytes < 15*mb || top.Bytes > 16*mb || top.GiB <= 0 {
|
||||
t.Errorf("top %+v", top)
|
||||
}
|
||||
for _, f := range r.Largest {
|
||||
if f.Depth > 2 || f.Depth < 1 {
|
||||
t.Errorf("%+v is outside depth 2", f)
|
||||
}
|
||||
if strings.Contains(f.Path, "overlay2") {
|
||||
t.Errorf("%s is deeper than 2", f.Path)
|
||||
}
|
||||
}
|
||||
if r.Bytes < 22*mb || r.Bytes > 24*mb {
|
||||
t.Errorf("total %d", r.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC2: given a path, a depth and a limit, then only that folder is measured, depth is held at 3 and limit at 100.
|
||||
func TestFoldersTakeAPathADepthAndALimitHeldAtThreeAndAHundred(t *testing.T) {
|
||||
f := aFullDisk(t)
|
||||
r := folders(t, f.machine(), map[string]any{"path": "/var/lib/docker", "depth": 1.0})
|
||||
if r.Path != "/var/lib/docker" || paths(r) != "/var/lib/docker/overlay2" {
|
||||
t.Errorf("%s: %s", r.Path, paths(r))
|
||||
}
|
||||
r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0, "limit": 2.0})
|
||||
if len(r.Largest) != 2 {
|
||||
t.Errorf("limit 2 gave %d", len(r.Largest))
|
||||
}
|
||||
r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0})
|
||||
if got := paths(r); !strings.Contains(got, "/home/jo/deep/er") || strings.Contains(got, "/home/jo/deep/er/est") {
|
||||
t.Errorf("depth 3 under /home is /home/jo/deep/er: %s", got)
|
||||
}
|
||||
tl := tool(t, f.machine(), "disk_folders")
|
||||
for _, bad := range []map[string]any{{"depth": 0.0}, {"limit": 0.0}, {"depth": "deep"}, {"path": "relative/dir"}, {"path": "/nope"}, {"path": "/var/log/big.log"}} {
|
||||
if _, err := tl.Run(bad); err == nil {
|
||||
t.Errorf("%v was accepted", bad)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 120; i++ {
|
||||
f.file(fmt.Sprintf("many/d%03d/f", i), "x")
|
||||
}
|
||||
if r := folders(t, f.machine(), map[string]any{"path": "/many", "limit": 500.0}); len(r.Largest) != 100 {
|
||||
t.Errorf("limit held at %d, not 100", len(r.Largest))
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC3: given a mount below the path, then it is never entered or counted, and is named.
|
||||
func TestFoldersNeverCrossIntoAnotherFilesystem(t *testing.T) {
|
||||
f := aFullDisk(t)
|
||||
m := f.machine()
|
||||
m.DevOf = func(path string, info fs.FileInfo) uint64 {
|
||||
if path == "/var/lib/docker" {
|
||||
return 7
|
||||
}
|
||||
return 1
|
||||
}
|
||||
r := folders(t, m, nil)
|
||||
if strings.Contains(paths(r), "docker") || len(r.Skipped) != 1 || r.Skipped[0] != "/var/lib/docker" {
|
||||
t.Errorf("%s, skipped %v", paths(r), r.Skipped)
|
||||
}
|
||||
if r.Bytes > 14*mb {
|
||||
t.Errorf("total %d includes the mount", r.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC4: given folders it cannot read, then they are counted and named and the rest is still answered.
|
||||
func TestUnreadableFoldersAreCountedAndNamed(t *testing.T) {
|
||||
f := aFullDisk(t)
|
||||
m := f.machine()
|
||||
real := os.ReadDir
|
||||
m.ReadDir = func(p string) ([]os.DirEntry, error) {
|
||||
if strings.HasSuffix(p, "/var/log") || strings.HasSuffix(p, "/overlay2/b") {
|
||||
return nil, fmt.Errorf("open %s: permission denied", p)
|
||||
}
|
||||
return real(p)
|
||||
}
|
||||
r := folders(t, m, map[string]any{"depth": 3.0})
|
||||
if r.Unreadable != 2 || strings.Join(r.UnreadableFolders, ",") != "/var/lib/docker/overlay2/b,/var/log" {
|
||||
t.Errorf("%d: %v", r.Unreadable, r.UnreadableFolders)
|
||||
}
|
||||
if r.Partial || !strings.Contains(strings.Join(r.Said, "\n"), "2 folders could not be read") {
|
||||
t.Errorf("%+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC5: given a walk that outlasts its bound, then it answers what it measured, partial, with where it stopped.
|
||||
func TestALongWalkAnswersPartialAndSaysWhereItStopped(t *testing.T) {
|
||||
f := aFullDisk(t)
|
||||
m := f.machine()
|
||||
now := time.Unix(0, 0)
|
||||
m.Clock = func() time.Time { now = now.Add(10 * time.Second); return now }
|
||||
r := folders(t, m, nil)
|
||||
if !r.Partial || r.StoppedAt == "" || len(r.Said) < 2 || !strings.HasPrefix(r.Said[0], "PARTIAL") || !strings.Contains(r.Said[0], r.StoppedAt) {
|
||||
t.Errorf("%+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC6: a hard link is counted once, and the tool changes nothing.
|
||||
func TestAHardLinkIsCountedOnce(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.megabytes("a/big", 5)
|
||||
if err := os.Link(f.root+"/a/big", f.root+"/a/again"); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
r := folders(t, f.machine(), nil)
|
||||
if r.Bytes > 6*mb {
|
||||
t.Errorf("counted twice: %d", r.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 1804/AC7: the bound sits below the 30 s the tool runner gives a call, so a partial answer reaches its caller.
|
||||
func TestTheWalkBoundIsBelowTheToolRunnersCallLimit(t *testing.T) {
|
||||
if FolderBudget >= 30*time.Second {
|
||||
t.Errorf("the budget %s is not below the 30 s a call gets", FolderBudget)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -53,6 +54,13 @@ type Machine struct {
|
||||
Run Runner
|
||||
Wait func(time.Duration)
|
||||
Statfs func(path string) (FSStat, error)
|
||||
|
||||
// The folder walk's seams, all optional: the folder reader, the filesystem a path is on, the clock and
|
||||
// the time the walk may take (60 s when zero).
|
||||
ReadDir func(path string) ([]os.DirEntry, error)
|
||||
DevOf func(path string, info fs.FileInfo) uint64
|
||||
Clock func() time.Time
|
||||
Budget time.Duration
|
||||
}
|
||||
|
||||
// Here is the machine this process runs on.
|
||||
|
||||
@@ -136,6 +136,34 @@ func Tools(m *Machine) []stdio.Tool {
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "disk_folders",
|
||||
Description: "Which folders fill a disk, like du -x: the largest folders under a path, measured on disk (allocated bytes, a hard link " +
|
||||
"once), staying on the path's one filesystem (a mount below it is named, never entered). path defaults to /, depth to 2 (at most 3), " +
|
||||
"limit to 20 (at most 100). Sizes in bytes and GiB. It reads every folder the tool runner can read, none of the content; folders " +
|
||||
"it cannot read are counted and named. It stops after 25 s and answers what it measured so far, marked partial, with where it stopped. " +
|
||||
"Replaces du. (r)",
|
||||
Input: map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "the absolute folder to measure (default /)"},
|
||||
"depth": map[string]any{"type": "integer", "description": "how many levels below path to rank (default 2, at most 3)"},
|
||||
"limit": map[string]any{"type": "integer", "description": "how many folders to answer (default 20, at most 100)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
depth, err := count(args, "depth", 2, 1, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit, err := count(args, "limit", 20, 1, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := text(args, "path")
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
return m.Folders(path, depth, limit)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "disk_filesystems",
|
||||
Description: "The space of every filesystem that stores data (not proc, tmpfs or overlay layers): size, used and free in GiB, " +
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"disk_load",
|
||||
"disk_top",
|
||||
"disk_filesystems",
|
||||
"disk_folders",
|
||||
"disk_devices",
|
||||
"cpu_top"
|
||||
],
|
||||
|
||||
@@ -160,6 +160,7 @@ A failure is an error naming how it failed, never an empty answer.
|
||||
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
|
||||
| `docker_networks` | r | networks, subnets, and the containers on each |
|
||||
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
|
||||
| `docker_volumes_remove_anonymous` | a | leftover anonymous volumes no container mounts in any state (so none the mesh holds), optionally created before a time; each other volume answered with why it stays; none can be named. **A dry run unless `dry_run` is false, which needs `why`. Never forced** (issue 1796). Sizes only when `sizes` is true (docker is slow on very many volumes); at most `limit` (default 200) per call |
|
||||
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise unless asked; **an exec's command line is shown with any secret it carried as `[redacted: <what it was>]`** |
|
||||
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
|
||||
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
|
||||
|
||||
@@ -944,16 +944,9 @@ func (c *Client) Volumes(ctx context.Context, unmountedOnly, sizes bool) (map[st
|
||||
}
|
||||
size := map[string]string{}
|
||||
if sizes {
|
||||
out, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
|
||||
if err != nil {
|
||||
if size, err = c.volumeSizes(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v struct{ Volumes []map[string]any }
|
||||
if err := json.Unmarshal([]byte(out), &v); err == nil {
|
||||
for _, vol := range v.Volumes {
|
||||
size[fmt.Sprint(vol["Name"])] = fmt.Sprint(vol["Size"])
|
||||
}
|
||||
}
|
||||
}
|
||||
vols := []map[string]any{}
|
||||
if len(lines(names)) > 0 {
|
||||
@@ -1215,3 +1208,134 @@ func (c *Client) DaemonConfig(ctx context.Context) (map[string]any, error) {
|
||||
answer["note"] = "keys read only at start take effect at the daemon's next restart; with live-restore on, a restart keeps every container running"
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// volumeSizes is every volume's size by name, from docker system df -v, which walks every volume:
|
||||
// on a machine with a thousand of them it does not answer within a call's bound.
|
||||
func (c *Client) volumeSizes(ctx context.Context) (map[string]string, error) {
|
||||
size := map[string]string{}
|
||||
out, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v struct{ Volumes []map[string]any }
|
||||
if err := json.Unmarshal([]byte(out), &v); err == nil {
|
||||
for _, vol := range v.Volumes {
|
||||
size[fmt.Sprint(vol["Name"])] = fmt.Sprint(vol["Size"])
|
||||
}
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// RemoveVolumesAsk is what docker_volumes_remove_anonymous is asked to take.
|
||||
type RemoveVolumesAsk struct {
|
||||
DryRun bool
|
||||
Why string
|
||||
CreatedBefore *time.Time
|
||||
Sizes bool
|
||||
Limit int // how many to remove in one call; 0 is 200
|
||||
}
|
||||
|
||||
// RemoveAnonymousVolumes removes the leftover anonymous volumes (novox/hq issue 1796): those docker
|
||||
// labels anonymous, which no container mounts in any state, so none a mesh-held container holds. The
|
||||
// classification is Volumes' own, not a copy. Every other volume is answered with why it stays. Each
|
||||
// is removed by its own `docker volume rm`, never forced and never `volume prune`, so one the daemon
|
||||
// refuses is reported and the rest still go.
|
||||
func (c *Client) RemoveAnonymousVolumes(ctx context.Context, a RemoveVolumesAsk) (map[string]any, error) {
|
||||
if !a.DryRun && strings.TrimSpace(a.Why) == "" {
|
||||
return nil, fmt.Errorf("why is required when dry_run is false")
|
||||
}
|
||||
all, err := c.Volumes(ctx, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates := []string{}
|
||||
skipped := []map[string]any{}
|
||||
skip := func(name, why string) { skipped = append(skipped, map[string]any{"name": name, "why": why}) }
|
||||
for _, v := range all["volumes"].([]map[string]any) {
|
||||
name := fmt.Sprint(v["name"])
|
||||
switch by := v["mounted_by"].([]map[string]any); {
|
||||
case v["anonymous"] != true:
|
||||
skip(name, "not anonymous: a named volume is data")
|
||||
case v["mesh_held"] == true:
|
||||
skip(name, "mounted by a container the mesh holds")
|
||||
case len(by) > 0:
|
||||
skip(name, fmt.Sprintf("mounted by %v (%v)", by[0]["container"], by[0]["state"]))
|
||||
case a.CreatedBefore != nil && !volumeOlder(fmt.Sprint(v["created"]), *a.CreatedBefore):
|
||||
skip(name, "not created before "+a.CreatedBefore.Format(time.RFC3339)+" (created "+fmt.Sprint(v["created"])+")")
|
||||
default:
|
||||
candidates = append(candidates, name)
|
||||
}
|
||||
}
|
||||
answer := map[string]any{"dry_run": a.DryRun, "candidates": len(candidates)}
|
||||
// Sizes are asked for, never needed: the listing walks every volume and may not answer, and
|
||||
// then the call answers without them. Nothing below depends on it.
|
||||
sizes := map[string]string{}
|
||||
if a.Sizes {
|
||||
if got, err := c.volumeSizes(ctx); err != nil {
|
||||
answer["sizes_unknown"] = err.Error()
|
||||
} else {
|
||||
sizes = got
|
||||
answer["sizes"] = sizes
|
||||
}
|
||||
}
|
||||
sum := func(names []string) int64 {
|
||||
var t int64
|
||||
for _, n := range names {
|
||||
t += max(Bytes(sizes[n]), 0)
|
||||
}
|
||||
return t
|
||||
}
|
||||
if a.DryRun {
|
||||
answer["skipped"] = skipped
|
||||
answer["would_remove"] = candidates
|
||||
if len(sizes) > 0 {
|
||||
answer["would_free_bytes"] = sum(candidates)
|
||||
}
|
||||
answer["note"] = "nothing was removed: call again with dry_run false and why to remove them (limit volumes at most per call)"
|
||||
return answer, nil
|
||||
}
|
||||
limit := a.Limit
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
take := candidates
|
||||
if len(take) > limit {
|
||||
take = candidates[:limit]
|
||||
}
|
||||
removed := []string{}
|
||||
// Chunks of a hundred in one `docker volume rm`, so a thousand volumes are ten calls; a chunk
|
||||
// docker refuses any of is done again one by one, so what it refuses is named.
|
||||
for i := 0; i < len(take); i += 100 {
|
||||
chunk := take[i:min(i+100, len(take))]
|
||||
if _, err := c.docker(ctx, append([]string{"volume", "rm"}, chunk...)...); err == nil {
|
||||
removed = append(removed, chunk...)
|
||||
continue
|
||||
}
|
||||
for _, name := range chunk {
|
||||
if _, err := c.docker(ctx, "volume", "rm", name); err != nil {
|
||||
// The batch took it before it refused another (or before its bound ran out):
|
||||
// a volume that is no longer there is a removed one.
|
||||
if strings.Contains(strings.ToLower(err.Error()), "no such volume") {
|
||||
removed = append(removed, name)
|
||||
continue
|
||||
}
|
||||
skip(name, "docker refused: "+err.Error())
|
||||
continue
|
||||
}
|
||||
removed = append(removed, name)
|
||||
}
|
||||
}
|
||||
answer["skipped"] = skipped
|
||||
answer["removed"], answer["count"], answer["why"] = removed, len(removed), a.Why
|
||||
answer["remaining"] = len(candidates) - len(take)
|
||||
if len(sizes) > 0 {
|
||||
answer["freed_bytes"] = sum(removed)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// volumeOlder is whether a volume's CreatedAt is before the cutoff; one that cannot be read is not.
|
||||
func volumeOlder(created string, cutoff time.Time) bool {
|
||||
t, err := time.Parse(time.RFC3339Nano, created)
|
||||
return err == nil && t.Before(cutoff)
|
||||
}
|
||||
|
||||
@@ -431,3 +431,145 @@ func TestEventsCoveringTheWholeWindowSayItIsCompleteAndFilterTheTypeHere(t *test
|
||||
t.Fatalf("a full buffer reaching past the window: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1796/AC1: only anonymous volumes nothing mounts go, each answered; a named, a mounted and a too young one stay with why.
|
||||
func TestRemoveAnonymousVolumesTakesOnlyTheUnmountedAnonymousOnes(t *testing.T) {
|
||||
f := machine().
|
||||
on("docker volume ls --quiet", Ran{Stdout: "webdata\nanonmounted\nold\nyoung\nnamed\n"}).
|
||||
on("docker volume inspect", Ran{Stdout: `[
|
||||
{"Name":"webdata","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
|
||||
{"Name":"old","Driver":"local","CreatedAt":"2026-09-01T00:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
|
||||
{"Name":"young","Driver":"local","CreatedAt":"2026-10-04T11:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
|
||||
{"Name":"named","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z"}]`}).
|
||||
on("docker system df --verbose", Ran{Stdout: `{"Volumes":[{"Name":"old","Size":"1.5MB"},{"Name":"young","Size":"1kB"}]}`}).
|
||||
on("docker volume rm old", Ran{Stdout: "old\n"})
|
||||
c := client(f, 1000)
|
||||
before := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
||||
got, err := c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true, Sizes: true, CreatedBefore: &before})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.ran("docker volume rm") || !reflect.DeepEqual(got["would_remove"], []string{"old"}) {
|
||||
t.Fatalf("dry run: %v %+v", got, f.calls)
|
||||
}
|
||||
if got["would_free_bytes"] != int64(1500000) || got["sizes"].(map[string]string)["old"] != "1.5MB" {
|
||||
t.Fatalf("dry run size: %v", got)
|
||||
}
|
||||
skipped := got["skipped"].([]map[string]any)
|
||||
why := map[string]string{}
|
||||
for _, s := range skipped {
|
||||
why[s["name"].(string)] = s["why"].(string)
|
||||
}
|
||||
if len(why) != 3 || !strings.Contains(why["webdata"], "mesh holds") || why["young"] == "" || why["named"] == "" {
|
||||
t.Fatalf("skipped: %v", skipped)
|
||||
}
|
||||
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "leftovers", Sizes: true, CreatedBefore: &before})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.ran("docker volume rm old") || got["count"] != 1 || !reflect.DeepEqual(got["removed"], []string{"old"}) {
|
||||
t.Fatalf("removed: %v %+v", got, f.calls)
|
||||
}
|
||||
if got["freed_bytes"] != int64(1500000) {
|
||||
t.Fatalf("freed: %v", got)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if len(c.args) > 1 && c.args[0] == "volume" && c.args[1] == "rm" && (len(c.args) != 3 || c.args[2] != "old") {
|
||||
t.Errorf("reached too far: %v", c.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1796/AC2: a volume that is mounted by a mesh-held container is never removed; the tool takes no name.
|
||||
func TestRemoveAnonymousVolumesRefusesANamedVolumeAndKeepsWhatTheMeshHolds(t *testing.T) {
|
||||
for _, tool := range tools(client(&fake{}, 1000)) {
|
||||
if tool.Name != "docker_volumes_remove_anonymous" {
|
||||
continue
|
||||
}
|
||||
for _, k := range []string{"name", "names", "volume", "volumes"} {
|
||||
if _, err := tool.Run(map[string]any{k: "x", "dry_run": false, "why": "w"}); err == nil {
|
||||
t.Errorf("%s accepted", k)
|
||||
}
|
||||
}
|
||||
if _, err := tool.Run(map[string]any{"dry_run": false}); err == nil {
|
||||
t.Error("a removal without why was accepted")
|
||||
}
|
||||
if _, err := tool.Run(map[string]any{"created_before": "yesterday"}); err == nil {
|
||||
t.Error("a bad time was accepted")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("tool not served")
|
||||
}
|
||||
|
||||
// 1796/AC3: on a machine with a very large set of volumes docker's size listing does not answer in
|
||||
// time; the tool still answers, and removal never depends on it.
|
||||
func TestRemoveAnonymousVolumesAnswersWhenTheSizeListingDoesNot(t *testing.T) {
|
||||
f := (&fake{}).
|
||||
on("docker ps --all --quiet --no-trunc", Ran{}).
|
||||
on("docker volume ls --quiet", Ran{Stdout: "a1\na2\na3\n"}).
|
||||
on("docker volume inspect", Ran{Stdout: `[{"Name":"a1","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a2","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a3","Labels":{"com.docker.volume.anonymous":""}}]`}).
|
||||
on("docker system df", Ran{Status: 124, Err: "no answer within 20 s"}).
|
||||
on("docker volume rm", Ran{Stdout: "ok\n"})
|
||||
c := client(f, 1000)
|
||||
got, err := c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true})
|
||||
if err != nil || f.ran("docker system df") || !reflect.DeepEqual(got["would_remove"], []string{"a1", "a2", "a3"}) {
|
||||
t.Fatalf("default dry run: %v %v %+v", got, err, f.calls)
|
||||
}
|
||||
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true, Sizes: true})
|
||||
if err != nil || !reflect.DeepEqual(got["would_remove"], []string{"a1", "a2", "a3"}) || got["sizes_unknown"] == nil {
|
||||
t.Fatalf("sizes asked, df silent: %v %v", got, err)
|
||||
}
|
||||
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w", Sizes: true, Limit: 2})
|
||||
if err != nil || got["count"] != 2 || got["remaining"] != 1 || !f.ran("docker volume rm a1 a2") {
|
||||
t.Fatalf("removal: %v %v %+v", got, err, f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1796/AC4: a batch docker half-did is settled by name: what is already gone counts as removed, what is in use stays with why.
|
||||
func TestRemoveAnonymousVolumesCountsWhatAFailedBatchAlreadyRemoved(t *testing.T) {
|
||||
f := (&fake{}).
|
||||
on("docker ps --all --quiet --no-trunc", Ran{}).
|
||||
on("docker volume ls --quiet", Ran{Stdout: "a1\na2\na3\n"}).
|
||||
on("docker volume inspect", Ran{Stdout: `[{"Name":"a1","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a2","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a3","Labels":{"com.docker.volume.anonymous":""}}]`}).
|
||||
on("docker volume rm a1 a2 a3", Ran{Status: 1, Stdout: "a1\n", Stderr: "Error response from daemon: remove a2: volume is in use - [c1]\n"}).
|
||||
on("docker volume rm a1", Ran{Status: 1, Stderr: "Error response from daemon: get a1: no such volume\n"}).
|
||||
on("docker volume rm a2", Ran{Status: 1, Stderr: "Error response from daemon: remove a2: volume is in use - [c1]\n"}).
|
||||
on("docker volume rm a3", Ran{Stdout: "a3\n"})
|
||||
got, err := client(f, 1000).RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sk := got["skipped"].([]map[string]any)
|
||||
if !reflect.DeepEqual(got["removed"], []string{"a1", "a3"}) || got["count"] != 2 || len(sk) != 1 || sk[0]["name"] != "a2" || !strings.Contains(sk[0]["why"].(string), "in use") {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1796/AC5: removal goes in batches of a hundred names.
|
||||
func TestRemoveAnonymousVolumesRemovesInBatchesOfAHundred(t *testing.T) {
|
||||
names, entries := []string{}, []string{}
|
||||
for i := 0; i < 250; i++ {
|
||||
n := fmt.Sprintf("v%03d", i)
|
||||
names = append(names, n)
|
||||
entries = append(entries, `{"Name":"`+n+`","Labels":{"com.docker.volume.anonymous":""}}`)
|
||||
}
|
||||
f := (&fake{}).
|
||||
on("docker ps --all --quiet --no-trunc", Ran{}).
|
||||
on("docker volume ls --quiet", Ran{Stdout: strings.Join(names, "\n")}).
|
||||
on("docker volume inspect", Ran{Stdout: "[" + strings.Join(entries, ",") + "]"}).
|
||||
on("docker volume rm", Ran{Stdout: "ok\n"})
|
||||
got, err := client(f, 1000).RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w", Limit: 250})
|
||||
if err != nil || got["count"] != 250 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
sizes := []int{}
|
||||
for _, c := range f.calls {
|
||||
if len(c.args) > 1 && c.args[0] == "volume" && c.args[1] == "rm" {
|
||||
sizes = append(sizes, len(c.args)-2)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(sizes, []int{100, 100, 50}) {
|
||||
t.Fatalf("batches %v", sizes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -286,6 +287,40 @@ func tools(c *Client) []stdio.Tool {
|
||||
return c.Volumes(ctx, flag(args, "unmounted", false), flag(args, "sizes", false))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_volumes_remove_anonymous",
|
||||
Description: "Remove the leftover anonymous volumes (novox/hq issue 1796): only a volume docker labels anonymous (as docker_volumes says), which no container mounts in any state " +
|
||||
"and so none the mesh holds, and with created_before only one created before it. Every other volume is answered with why it stays; a named volume is data and is never taken, " +
|
||||
"and no volume can be named. Each is removed on its own, never forced; the answer has the count and names removed. With sizes true the answer has each volume's size and the bytes freed; docker's size listing is slow on many volumes, so it is asked for, and when it does not answer the call answers without it. At most limit are removed per call, in batches. " +
|
||||
"A dry run by default; dry_run false needs why. Replaces docker volume rm and docker volume prune. (a)",
|
||||
Input: map[string]any{
|
||||
"dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"},
|
||||
"why": map[string]any{"type": "string", "description": "why: required when dry_run is false"},
|
||||
"created_before": map[string]any{"type": "string", "description": "RFC 3339 time: only volumes created before it (default: any age)"},
|
||||
"sizes": map[string]any{"type": "boolean", "description": "also read each volume's size and the bytes freed (default false: docker walks every volume for it and on a machine with very many may not answer; the call then answers without sizes)"},
|
||||
"limit": map[string]any{"type": "integer", "description": "how many to remove in one call (default 200, at most 2000); the answer says how many remain, call again for them"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
for _, k := range []string{"name", "names", "volume", "volumes"} {
|
||||
if _, ok := args[k]; ok {
|
||||
return nil, fmt.Errorf("%s: this tool takes no volume by name; it removes only unmounted anonymous volumes", k)
|
||||
}
|
||||
}
|
||||
limit, err := bounded(args, "limit", 200, 2000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ask := RemoveVolumesAsk{DryRun: flag(args, "dry_run", true), Why: optional(args, "why"), Sizes: flag(args, "sizes", false), Limit: limit}
|
||||
if s := optional(args, "created_before"); s != "" {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("created_before must be an RFC 3339 time such as 2026-10-01T00:00:00Z")
|
||||
}
|
||||
ask.CreatedBefore = &t
|
||||
}
|
||||
return c.RemoveAnonymousVolumes(ctx, ask)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_events",
|
||||
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked; " +
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"docker_disk_usage",
|
||||
"docker_networks",
|
||||
"docker_volumes",
|
||||
"docker_volumes_remove_anonymous",
|
||||
"docker_events",
|
||||
"docker_daemon_config",
|
||||
"docker_unlabelled",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// **A consumer that cannot be bound yet is bound again, never fatal** (novox/hq issue 1793). When the tool runtime
|
||||
// starts this bundle, the runtime's answer to "bind this module's consumer" can be "no responders" for a few seconds (the
|
||||
// runtime restarted by a delivery, the bus not yet serving the stream). That rejection, left unhandled at the top of
|
||||
// index.ts, ended the bundle: nothing heard `mesh-controller.checked` again, and a verdict that landed later never
|
||||
// reached the forge's statuses. A bind that fails is said and asked again with a growing wait until it holds.
|
||||
|
||||
/** How long to wait before the nth new attempt (1-based): 2 s, 5 s, 10 s, then every 30 s. */
|
||||
export function bindWait(attempt: number): number {
|
||||
return [2_000, 5_000, 10_000][attempt - 1] ?? 30_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds with `bind`, asking again after each failure, until it holds. `sleep` is the wait; `say` how a failure is said.
|
||||
* Never rejects; resolves once the bind holds.
|
||||
*/
|
||||
export async function bindUntilHeld(what: string, bind: () => Promise<unknown>,
|
||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
say: (line: string) => void = (line) => console.error(line)): Promise<void> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
await bind();
|
||||
if (attempt > 1) say(`[gitea] ${what} is bound after ${attempt} attempts`);
|
||||
return;
|
||||
} catch (err) {
|
||||
const wait = bindWait(attempt);
|
||||
say(`[gitea] ${what} could not be bound (${err instanceof Error ? err.message : String(err)}); asking again in ${wait / 1000}s`);
|
||||
await sleep(wait);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **Starts the binds without making the bundle wait for them** (novox/hq issue 1793). The launcher imports the bundle
|
||||
* and then answers the runtime's handshake, which the runtime gives 10 s and then kills the bundle without starting it
|
||||
* again; a bind awaited at the top of the module that stays refused longer ends the bundle all the same. So this
|
||||
* returns at once: the binds run in the order given, each until it holds, and `done` is called after the last.
|
||||
*/
|
||||
export function bindInBackground(binds: [what: string, bind: () => Promise<unknown>][], done: () => void,
|
||||
sleep?: (ms: number) => Promise<void>, say?: (line: string) => void): void {
|
||||
void (async () => {
|
||||
for (const [what, bind] of binds) await bindUntilHeld(what, bind, sleep, say);
|
||||
done();
|
||||
})();
|
||||
}
|
||||
+15
-11
@@ -47,6 +47,7 @@ import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateS
|
||||
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
|
||||
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
||||
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
|
||||
import { bindInBackground } from "./bind.js";
|
||||
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
|
||||
|
||||
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
||||
@@ -590,15 +591,18 @@ if (gitea) {
|
||||
tick(() => pollRepos(client), 60_000);
|
||||
tick(() => pollMerged(client), () => pace.every());
|
||||
tick(() => pollPulls(client), () => pace.every());
|
||||
await on("mesh-controller.checked", (event) => {
|
||||
const c = (event.body ?? {}) as Checked;
|
||||
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
|
||||
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
|
||||
if (!c.group && c.owner && c.repo && c.commit) {
|
||||
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
|
||||
}
|
||||
return setVerdict(client, event);
|
||||
});
|
||||
await on("node-build-agent.started", buildStarted);
|
||||
console.log("[gitea] watching for new repositories and merged pull requests");
|
||||
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its
|
||||
// handshake in 10 s, so the import must end while a refused bind is still asked again.
|
||||
bindInBackground([
|
||||
["mesh-controller.checked", () => on("mesh-controller.checked", (event) => {
|
||||
const c = (event.body ?? {}) as Checked;
|
||||
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
|
||||
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
|
||||
if (!c.group && c.owner && c.repo && c.commit) {
|
||||
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
|
||||
}
|
||||
return setVerdict(client, event);
|
||||
})],
|
||||
["node-build-agent.started", () => on("node-build-agent.started", buildStarted)],
|
||||
], () => console.log("[gitea] watching for new repositories and merged pull requests"));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { mergeableUnder, newestByContext } from "./checks.js";
|
||||
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
|
||||
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
|
||||
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk } from "./trunk.js";
|
||||
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
|
||||
|
||||
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
|
||||
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
|
||||
@@ -117,6 +117,14 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
|
||||
const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`);
|
||||
const head = twelve(pull.head_sha), now = twelve(trunkHead);
|
||||
// **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own
|
||||
// check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was
|
||||
// refused the same way for ever. The composed check is mesh-delivery's to ask; this says whose verdict is awaited.
|
||||
if (waitsForComposed(checks[CHECK_CONTEXT])) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[CHECK_CONTEXT]?.description}; the ` +
|
||||
`verdict that names its trunk commit is its group's composed check's, and mesh-delivery asks it, this tool asks no check ` +
|
||||
`(main is now ${now}). Ask again once the group's composed check has passed. Nothing was merged`);
|
||||
}
|
||||
if (at.onItsWay) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
|
||||
`${now}. Its check is already asked and not yet answered (${checks[CHECK_CONTEXT]?.description}), so it is not ` +
|
||||
|
||||
@@ -226,6 +226,10 @@ function repoWrong(repo: Layer | undefined, facts: RepoCheckFacts): boolean {
|
||||
return !!repo && repoStatus(repo, facts).state !== "success";
|
||||
}
|
||||
|
||||
/** What a group member's gate says while the group's composed check is the one that judges it: the member's own check can say
|
||||
* nothing more of it, so asking that check again brings no verdict that names a trunk (novox/hq issue 1106). */
|
||||
export const WAITS_FOR_COMPOSED = "waits for its group's composed check";
|
||||
|
||||
/** The forge's status for the gate. */
|
||||
export function statusFor(c: Checked): CommitStatus {
|
||||
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
|
||||
@@ -260,7 +264,7 @@ export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: st
|
||||
// set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it.
|
||||
const out: CommitStatus[] = [];
|
||||
if (!(gateNow ?? "").includes(" composed: ")) {
|
||||
out.push({ state: "pending", context: CHECK_CONTEXT, description: "waits for its group's composed check" });
|
||||
out.push({ state: "pending", context: CHECK_CONTEXT, description: WAITS_FOR_COMPOSED });
|
||||
}
|
||||
const repo = c["repo-check"];
|
||||
if (repo) out.push(judgedFirst(c, repoStatus(repo, facts)));
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// **A consumer that cannot be bound yet is bound again** (novox/hq issue 1793): the runtime's "no responders" at the
|
||||
// bundle's start, seen 2026-10-22 00:28:37 on mesh-catalog#427's verdict, ended the bundle and the verdict was never
|
||||
// written on the forge.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { bindInBackground, bindUntilHeld, bindWait } from "../dist/bind.js";
|
||||
|
||||
test("a bind refused with no responders is asked again until it holds, and says so", async () => {
|
||||
let calls = 0;
|
||||
const waits: number[] = [];
|
||||
const said: string[] = [];
|
||||
await bindUntilHeld("mesh-controller.checked", async () => {
|
||||
calls++;
|
||||
if (calls < 4) throw new Error("binding gitea's consumer novox_gitea: nats: no responders available for request");
|
||||
}, async (ms) => { waits.push(ms); }, (l) => said.push(l));
|
||||
assert.equal(calls, 4);
|
||||
assert.deepEqual(waits, [2_000, 5_000, 10_000]);
|
||||
assert.equal(said.length, 4);
|
||||
assert.match(said[0], /no responders available for request.*asking again in 2s/);
|
||||
assert.match(said[3], /is bound after 4 attempts/);
|
||||
});
|
||||
|
||||
test("a bind that holds the first time says nothing and waits for nothing", async () => {
|
||||
const said: string[] = [];
|
||||
await bindUntilHeld("x", async () => {}, async () => assert.fail("waited"), (l) => said.push(l));
|
||||
assert.deepEqual(said, []);
|
||||
});
|
||||
|
||||
test("the wait grows to 30 s and stays", () => {
|
||||
assert.deepEqual([1, 2, 3, 4, 50].map(bindWait), [2_000, 5_000, 10_000, 30_000, 30_000]);
|
||||
});
|
||||
|
||||
test("the binds start without being waited for: the caller returns while a bind keeps refusing, and they go in order", async () => {
|
||||
const order: string[] = [];
|
||||
let finished = false;
|
||||
let refusals = 0;
|
||||
const gate = new Promise<void>((r) => setTimeout(r, 0));
|
||||
bindInBackground([
|
||||
["first", async () => { refusals++; if (refusals < 3) throw new Error("nats: no responders available for request"); order.push("first"); }],
|
||||
["second", async () => { order.push("second"); }],
|
||||
], () => { finished = true; }, async () => { await gate; }, () => {});
|
||||
// Returned at once, with the first bind refused and nothing done.
|
||||
assert.equal(finished, false);
|
||||
assert.deepEqual(order, []);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.deepEqual(order, ["first", "second"]);
|
||||
assert.equal(finished, true);
|
||||
});
|
||||
|
||||
test("a bind that never holds does not stop the caller from returning", () => {
|
||||
let returned = false;
|
||||
bindInBackground([["x", async () => { throw new Error("no responders"); }]], () => assert.fail("done"),
|
||||
() => new Promise(() => {}), () => {});
|
||||
returned = true;
|
||||
assert.equal(returned, true);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// **The bundle's import ends while a bind is refused** (novox/hq issue 1793). The launcher imports index.js and then answers
|
||||
// node-tools' handshake, which has 10 s before the bundle is killed and never started again. A bind awaited at the top
|
||||
// of index.ts, refused for longer, ended the bundle at 00:28:37 on 2026-10-22. Run in a child: the polls' timers keep a
|
||||
// process alive, and the child says "imported" and exits as soon as the import resolves.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
test("importing the bundle resolves while the broker keeps refusing the binds", async () => {
|
||||
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
|
||||
const script = `
|
||||
const { useBroker } = await import("@novox/mesh-sdk/messaging");
|
||||
useBroker(() => ({
|
||||
subscribe: async () => { throw new Error("binding gitea's consumer novox_gitea: nats: no responders available for request"); },
|
||||
publish: async () => {}, request: async () => { throw new Error("no responders"); },
|
||||
handle: async () => () => {}, close: async () => {},
|
||||
}));
|
||||
await import(${JSON.stringify(index)});
|
||||
console.log("imported");
|
||||
process.exit(0);`;
|
||||
const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_URL: "http://127.0.0.1:1" };
|
||||
for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE"]) delete env[k];
|
||||
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
const ended = new Promise<void>((r) => child.on("exit", () => r()));
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
|
||||
await ended;
|
||||
clearTimeout(timer);
|
||||
assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`);
|
||||
});
|
||||
@@ -339,6 +339,30 @@ test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk
|
||||
assert.equal(state.emitted.length, 4, "a merge asks no check");
|
||||
});
|
||||
|
||||
// **A group member whose gate waits for its group's composed check is not announced again by the merge tool** (novox/hq issue
|
||||
// 1106): the member's own check can only say "composed" again, so each refusal's ask brought a verdict that names no trunk and
|
||||
// the next merge was refused the same way, for ever. The tool says whose verdict is awaited and asks nothing.
|
||||
test("gitea_merge_pull_request asks no check of a member that waits for its group's composed check", async () => {
|
||||
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
|
||||
state.trunk = TRUNK;
|
||||
const before = state.emitted.length;
|
||||
state.statuses = [{ context: "mesh/merge-gate", state: "pending", description: "waits for its group's composed check" },
|
||||
{ context: "mesh/repo-check", state: "success", description: "pass: y" }];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await assert.rejects(merge(), (err: Error) => {
|
||||
assert.match(err.message, /waits for its group's composed check/);
|
||||
assert.match(err.message, /mesh-delivery asks it, this tool asks no check/);
|
||||
assert.match(err.message, /Nothing was merged/);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
assert.equal(state.emitted.length, before, "no check is announced again and again");
|
||||
assert.equal(state.merged, false);
|
||||
// Once the composed verdict names the member's unmoved trunk, it merges.
|
||||
state.statuses = judgedAt(TRUNK);
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
// A merge lands only the head that was reviewed (novox/hq issue 381): mesh-controller#197 was reviewed at one head,
|
||||
// force-pushed, checked green at the new one, and merged there, because the tool merged whatever the head was.
|
||||
test("gitea_merge_pull_request merges only the head it is named, and refuses one that is not the head", async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, WAITS_FOR_COMPOSED } from "./pulls.js";
|
||||
|
||||
/** A commit as a verdict names it: its first twelve characters. */
|
||||
export function twelve(sha: string | undefined): string {
|
||||
@@ -58,6 +58,11 @@ export function checkOnItsWay(gate: HeadCheck | undefined): boolean {
|
||||
return d.startsWith("waiting in the build queue") || d.startsWith("building on ") || d.startsWith("checking again");
|
||||
}
|
||||
|
||||
/** Whether a gate says it waits for its group's composed check. */
|
||||
export function waitsForComposed(gate: HeadCheck | undefined): boolean {
|
||||
return !!gate && gate.state === "pending" && (gate.description ?? "").startsWith(WAITS_FOR_COMPOSED);
|
||||
}
|
||||
|
||||
/**
|
||||
* The statuses a trunk's move puts back to pending on one open pull request's head, from what the forge holds on it
|
||||
* now: none when it has no merge check yet, when a check is pending (on its way, or already out of date — its
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
// ForgeAlias is the ssh name the forge-account provision sets up in the account's ssh files (ADR 0409 §1); the
|
||||
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
|
||||
const ForgeAlias = "mesh-forge"
|
||||
@@ -37,6 +39,18 @@ type FetchResult struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Commit string `json:"commit"`
|
||||
Modules []string `json:"modules"`
|
||||
// The merge preview (ADR 0484 clause 3), only when the call gave a base: the trunk commit, the merge commit of the
|
||||
// head onto it, the paths the merge changes, and the tree of each module directory (a directory holding a
|
||||
// module.json) in the merge. A head that does not merge cleanly is an answer: Merge is empty, Conflicts names the
|
||||
// files, and the workspace is left at the trunk commit.
|
||||
Base string `json:"base,omitempty"`
|
||||
Merge string `json:"merge,omitempty"`
|
||||
Conflicts []string `json:"conflicts,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
|
||||
// Beside are the sibling repositories placed as plain directories under the workspace's beside directory, which
|
||||
// build_go_test hands to the tests as MESH_CHECK_BESIDE (ADR 0484 clause 4).
|
||||
Beside []Sibling `json:"beside,omitempty"`
|
||||
}
|
||||
|
||||
// fetchTool is the fetch step tool.
|
||||
@@ -52,6 +66,12 @@ func (m *Module) fetchTool() StepTool {
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
|
||||
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
|
||||
"ref": map[string]any{"type": "string", "description": "refs/pull/<n>/head when only a pull request's head carries the commit"},
|
||||
"base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"},
|
||||
"beside": map[string]any{"type": "array", "description": "optional sibling repositories the tests read, each placed as a plain directory (no symlink) under the workspace's beside/<name>, which build_go_test gives the tests as MESH_CHECK_BESIDE",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
|
||||
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
|
||||
}, "required": []string{"repository", "commit"}}},
|
||||
},
|
||||
"required": []string{"repository", "commit"},
|
||||
},
|
||||
@@ -64,6 +84,14 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
repository, _ := args["repository"].(string)
|
||||
commit, _ := args["commit"].(string)
|
||||
ref, _ := args["ref"].(string)
|
||||
base, _ := args["base"].(string)
|
||||
if base != "" && !commitOK.MatchString(base) {
|
||||
return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base)
|
||||
}
|
||||
siblings, err := siblingsArg(args["beside"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
|
||||
return nil, fmt.Errorf("%q is not a repository, <owner>/<name>", repository)
|
||||
}
|
||||
@@ -74,14 +102,27 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
if err := os.MkdirAll(tree, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remote := "ssh://" + ForgeAlias + "/" + repository + ".git"
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref); err != nil {
|
||||
remote := m.remote(repository)
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, m.FetchOptions...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := FetchResult{Workspace: id, Commit: commit}
|
||||
if base != "" {
|
||||
if err := m.merge(ctx, &res, tree, remote, base, commit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if res.Beside, err = m.placeSiblings(ctx, root, siblings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dirs, err := m.goModDirs(ctx, tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Modules = dirs
|
||||
if base != "" && res.Merge == "" {
|
||||
return res, nil // a conflict: the tree is at the trunk, nothing of the head is built or tested
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if _, err := os.Stat(filepath.Join(tree, d, "vendor", "modules.txt")); err == nil {
|
||||
continue // a module that vendors resolves nothing
|
||||
@@ -93,7 +134,7 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return FetchResult{Workspace: id, Commit: commit, Modules: dirs}, nil
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// goModDirs are the directories of the tree that hold a go.mod, relative to it, in order.
|
||||
@@ -104,7 +145,7 @@ func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) {
|
||||
}
|
||||
dirs := []string{}
|
||||
for _, l := range strings.Split(r.Stdout, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
if l = strings.TrimSpace(l); l != "" && !inTestdata(l) {
|
||||
dirs = append(dirs, filepath.ToSlash(filepath.Dir(l)))
|
||||
}
|
||||
}
|
||||
@@ -112,6 +153,17 @@ func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) {
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
// inTestdata says a path lies under a directory named testdata, whose go.mod files are fixtures Go ignores, not modules
|
||||
// of the tree (issue 1810).
|
||||
func inTestdata(path string) bool {
|
||||
for _, seg := range strings.Split(filepath.ToSlash(path), "/") {
|
||||
if seg == "testdata" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// goEnv is what a go command of a step is given beyond the base environment: the installed go alone (GOTOOLCHAIN=local, ADR 0477 §1: no toolchain is ever downloaded),
|
||||
// modules resolved by their go.mod and go.sum, the module's own caches, the mesh's own modules fetched directly (by
|
||||
// git, through the account's rewrite of the forge's address to ssh), and nothing that asks a question. For a directory
|
||||
|
||||
@@ -30,7 +30,8 @@ type fakeStep struct {
|
||||
gomods string
|
||||
gate chan struct{} // when set, the first git command waits for it
|
||||
goRule func(line string) forgekey.Ran
|
||||
vendors []string // directories (relative to the tree) that get a vendor/modules.txt at checkout
|
||||
vendors []string // directories (relative to the tree) that get a vendor/modules.txt at checkout
|
||||
rule func(line string) (forgekey.Ran, bool) // answers a command before the defaults do
|
||||
}
|
||||
|
||||
func (f *fakeStep) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
@@ -41,6 +42,11 @@ func (f *fakeStep) run(ctx context.Context, env []string, name string, args ...s
|
||||
if f.gate != nil && strings.Contains(line, " init ") {
|
||||
<-f.gate
|
||||
}
|
||||
if f.rule != nil {
|
||||
if r, ok := f.rule(line); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case name == "git" && strings.Contains(line, " rev-parse HEAD"):
|
||||
return forgekey.Ran{Stdout: commitSHA + "\n"}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// 1810/AC1: the go.mod files under a testdata directory are fixtures Go itself ignores, not modules of the tree, so
|
||||
// the build modules neither test nor digest them (a repository holding this module held one whose test reads
|
||||
// MESH_CHECK_BESIDE, which failed every check of it).
|
||||
func TestGoModDirsLeaveOutTheFixturesUnderTestdata(t *testing.T) {
|
||||
m := &Module{}
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
return forgekey.Ran{Stdout: "checks/deps/go.mod\ngo.mod\nmodules/x/cmd/x/testdata/repos/beside/go.mod\nmodules/x/go.mod\ntestdata/go.mod\n"}
|
||||
}
|
||||
got, err := m.goModDirs(context.Background(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{".", "checks/deps", "modules/x"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("go.mod directories = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func (m *Module) integration(ctx context.Context, workspace, tree string, packag
|
||||
return nil, fmt.Errorf("the integration level's bus: %w", err)
|
||||
}
|
||||
defer stopBus()
|
||||
env := []string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}
|
||||
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.besideEnv(workspace)...)
|
||||
// Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run
|
||||
// below finds them built. Whether they compile is for the run to say; its failure is not read here.
|
||||
for _, d := range dirs {
|
||||
|
||||
@@ -37,6 +37,10 @@ type Module struct {
|
||||
// both outside the account's home (ADR 0466 §1); Home is the account's home.
|
||||
Run forgekey.Runner
|
||||
Home, Work, Cache string
|
||||
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
|
||||
// the SDK's fetch and merge. A test points them at a local repository (buildstep.AllowFileRemote).
|
||||
RemoteBase string
|
||||
FetchOptions []buildstep.Option
|
||||
// HTTP fetches the files an archive artifact is pinned to (url and sha256); a client of its own with a fifteen-minute
|
||||
// timeout when nil. A test serves its own.
|
||||
HTTP *http.Client
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
// The merge preview and the siblings of a fetch (novox/hq ADR 0484 clauses 3 and 4; issues 1675 and 1677). The merge
|
||||
// itself is the SDK's (buildstep.MergeInto), the one every build module's fetch step makes; what is here is the answer
|
||||
// built from it and the placing of the sibling repositories the tests read.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// Sibling is a repository placed beside the fetched tree: its name (the directory under beside/) and its commit.
|
||||
type Sibling struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
|
||||
// remote is the address a repository is fetched from: the forge over ssh as the account, or RemoteBase when a test set it.
|
||||
func (m *Module) remote(repository string) string {
|
||||
base := m.RemoteBase
|
||||
if base == "" {
|
||||
base = "ssh://" + ForgeAlias + "/"
|
||||
}
|
||||
return base + repository + ".git"
|
||||
}
|
||||
|
||||
// merge makes the merge preview of the head onto base and writes the answer into res.
|
||||
func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base, commit string) error {
|
||||
mr, err := buildstep.MergeInto(ctx, m.Run, tree, remote, base, commit, m.FetchOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Base = mr.Base
|
||||
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
|
||||
if res.Merge == "" {
|
||||
return nil
|
||||
}
|
||||
res.Trees, err = buildstep.ModuleTrees(ctx, m.Run, nil, tree, res.Merge)
|
||||
return err
|
||||
}
|
||||
|
||||
type siblingArg struct{ repository, commit string }
|
||||
|
||||
func siblingsArg(v any) ([]siblingArg, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("beside is a list of {repository, commit}")
|
||||
}
|
||||
var out []siblingArg
|
||||
seen := map[string]bool{}
|
||||
for _, e := range list {
|
||||
o, _ := e.(map[string]any)
|
||||
repository, _ := o["repository"].(string)
|
||||
commit, _ := o["commit"].(string)
|
||||
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
|
||||
return nil, fmt.Errorf("beside: %q is not a repository, <owner>/<name>", repository)
|
||||
}
|
||||
if !commitOK.MatchString(commit) {
|
||||
return nil, fmt.Errorf("beside: %q is not a full commit (40 hex digits)", commit)
|
||||
}
|
||||
name := repository[strings.Index(repository, "/")+1:]
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("beside: two repositories are named %s", name)
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, siblingArg{repository, commit})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BesideDir is where a workspace's siblings are placed, and what MESH_CHECK_BESIDE names.
|
||||
func (m *Module) besideDir(workspace string) string {
|
||||
return filepath.Join(m.Work, workspace, "beside")
|
||||
}
|
||||
|
||||
// placeSiblings fetches each sibling into beside/<name> of the workspace root, as a plain directory. A symlink anywhere
|
||||
// in a placed tree fails the step naming it: the mesh creates none, and a test must not be led out of its sibling.
|
||||
func (m *Module) placeSiblings(ctx context.Context, root string, siblings []siblingArg) ([]Sibling, error) {
|
||||
var placed []Sibling
|
||||
for _, s := range siblings {
|
||||
name := s.repository[strings.Index(s.repository, "/")+1:]
|
||||
dir := filepath.Join(root, "beside", name)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, dir, m.remote(s.repository), s.commit, "", m.FetchOptions...); err != nil {
|
||||
return nil, fmt.Errorf("beside %s: %w", s.repository, err)
|
||||
}
|
||||
if link := firstSymlink(dir); link != "" {
|
||||
return nil, fmt.Errorf("beside %s holds a symlink, %s", s.repository, link)
|
||||
}
|
||||
placed = append(placed, Sibling{Repository: s.repository, Commit: s.commit, Dir: name})
|
||||
}
|
||||
sort.Slice(placed, func(i, j int) bool { return placed[i].Dir < placed[j].Dir })
|
||||
return placed, nil
|
||||
}
|
||||
|
||||
func firstSymlink(dir string) string {
|
||||
var found string
|
||||
filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err == nil && d.Type()&fs.ModeSymlink != 0 && found == "" {
|
||||
found, _ = filepath.Rel(dir, p)
|
||||
return fs.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// The fetch step's merge and siblings on real git repositories and the real go (issues 1675 and 1677): they start
|
||||
// processes, so they carry the tag; preview_test.go proves the commands with a fake runner.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func git(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func put(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// forge is a directory of bare repositories <owner>/<name>.git standing in for the forge, and a module fetching from it.
|
||||
type forge struct {
|
||||
t *testing.T
|
||||
root string
|
||||
}
|
||||
|
||||
func newForge(t *testing.T) *forge { return &forge{t: t, root: t.TempDir()} }
|
||||
|
||||
// repo makes the repository owner/name with the files on main and answers its work directory and the commit.
|
||||
func (f *forge) repo(name string, files map[string]string) (work, commit string) {
|
||||
bare := filepath.Join(f.root, "forge", name+".git")
|
||||
work = filepath.Join(f.root, "work", name)
|
||||
os.MkdirAll(bare, 0o755)
|
||||
os.MkdirAll(work, 0o755)
|
||||
git(f.t, bare, "init", "-q", "--bare")
|
||||
git(f.t, work, "init", "-q", "-b", "main")
|
||||
for n, c := range files {
|
||||
put(f.t, work, n, c)
|
||||
}
|
||||
git(f.t, work, "add", ".")
|
||||
git(f.t, work, "commit", "-q", "-m", "one")
|
||||
git(f.t, work, "push", "-q", bare, "HEAD:refs/heads/main")
|
||||
return work, git(f.t, work, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
// commit makes a commit off `from` in the repository's work directory and publishes it as the pull request's head.
|
||||
func (f *forge) commit(name, work, from string, files map[string]string, ref string) string {
|
||||
git(f.t, work, "checkout", "-q", "-B", "b"+ref[len(ref)-3:], from)
|
||||
for n, c := range files {
|
||||
put(f.t, work, n, c)
|
||||
}
|
||||
git(f.t, work, "add", ".")
|
||||
git(f.t, work, "commit", "-q", "-m", "change")
|
||||
sha := git(f.t, work, "rev-parse", "HEAD")
|
||||
git(f.t, work, "push", "-q", "-f", filepath.Join(f.root, "forge", name+".git"), "HEAD:"+ref)
|
||||
return sha
|
||||
}
|
||||
|
||||
func (f *forge) module(t *testing.T) (*Module, *keptStates) {
|
||||
m, states := fetchModule(t, &fakeStep{})
|
||||
m.Run = StepRunner(m.Home)
|
||||
m.RemoteBase = "file://" + filepath.Join(f.root, "forge") + "/"
|
||||
m.FetchOptions = []buildstep.Option{buildstep.AllowFileRemote()}
|
||||
return m, states
|
||||
}
|
||||
|
||||
// waitLong is waitEnd for a step that runs the real go, whose first build in a cold cache takes longer than 3 s.
|
||||
func waitLong(t *testing.T, s *keptStates) delivery.Step {
|
||||
t.Helper()
|
||||
select {
|
||||
case key := <-s.ended:
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v := s.steps[key]
|
||||
return v[len(v)-1]
|
||||
case <-time.After(2 * time.Minute):
|
||||
t.Fatal("the step never ended on its key")
|
||||
return delivery.Step{}
|
||||
}
|
||||
}
|
||||
|
||||
func fetched(t *testing.T, m *Module, states *keptStates, args map[string]any) (FetchResult, delivery.Step) {
|
||||
t.Helper()
|
||||
if _, err := fetchTool(t, m)(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
var res FetchResult
|
||||
if st.State == delivery.StepPassed {
|
||||
if err := json.Unmarshal(st.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return res, st
|
||||
}
|
||||
|
||||
var baseFiles = map[string]string{
|
||||
"go.mod": "module fixture/m\n\ngo 1.26\n",
|
||||
"a.txt": "one\ntwo\nthree\n",
|
||||
"modules/m/module.json": "{\"name\":\"m\"}\n",
|
||||
"modules/m/go.sum": "",
|
||||
"modules/m/m.txt": "m\n",
|
||||
"modules/other/other.txt": "o\n",
|
||||
}
|
||||
|
||||
// 1675/AC1 and AC2 on real git: a clean merge answers the merge commit, the changed paths (the trunk's change is not
|
||||
// among them) and the tree of each module directory, which equals git's own; a conflict is a passed step naming the files,
|
||||
// leaving the workspace at the trunk commit; and no base is today's fetch.
|
||||
func TestRealFetchMergesConflictsAndPlainlyFetches(t *testing.T) {
|
||||
f := newForge(t)
|
||||
work, fork := f.repo("o/r", baseFiles)
|
||||
trunk := f.commit("o/r", work, fork, map[string]string{"modules/other/other.txt": "trunk\n", "a.txt": "one\ntwo\nTRUNK\n"}, "refs/heads/main")
|
||||
clean := f.commit("o/r", work, fork, map[string]string{"modules/m/m.txt": "head\n"}, "refs/pull/1/head")
|
||||
clash := f.commit("o/r", work, fork, map[string]string{"a.txt": "one\ntwo\nHEAD\n"}, "refs/pull/2/head")
|
||||
m, states := f.module(t)
|
||||
|
||||
res, st := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head", "base": trunk})
|
||||
if st.State != delivery.StepPassed || res.Merge == "" || res.Base != trunk || res.Commit != clean || len(res.Conflicts) != 0 {
|
||||
t.Fatalf("clean: %s %s", st.State, st.Result)
|
||||
}
|
||||
if strings.Join(res.Changed, ",") != "modules/m/m.txt" {
|
||||
t.Errorf("changed = %v, want the head's own change alone", res.Changed)
|
||||
}
|
||||
tree := res.Trees["modules/m"]
|
||||
if want := git(t, filepath.Join(m.Work, res.Workspace, "tree"), "rev-parse", res.Merge+":modules/m"); tree.Tree != want {
|
||||
t.Errorf("tree = %s, want %s", tree.Tree, want)
|
||||
}
|
||||
if tree.Manifest != sha256Hex([]byte("{\"name\":\"m\"}\n")) || tree.Locks["go.sum"] != sha256Hex(nil) {
|
||||
t.Errorf("manifest and locks = %+v", tree)
|
||||
}
|
||||
if got := git(t, filepath.Join(m.Work, res.Workspace, "tree"), "rev-parse", "HEAD"); got != res.Merge {
|
||||
t.Errorf("the workspace is at %s, want the merge %s", got, res.Merge)
|
||||
}
|
||||
|
||||
res, st = fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clash, "ref": "refs/pull/2/head", "base": trunk})
|
||||
if st.State != delivery.StepPassed || res.Merge != "" || strings.Join(res.Conflicts, ",") != "a.txt" {
|
||||
t.Fatalf("conflict: %s %s", st.State, st.Result)
|
||||
}
|
||||
if got := git(t, filepath.Join(m.Work, res.Workspace, "tree"), "rev-parse", "HEAD"); got != trunk {
|
||||
t.Errorf("after a conflict the workspace is at %s, want the trunk %s", got, trunk)
|
||||
}
|
||||
|
||||
res, st = fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head"})
|
||||
if st.State != delivery.StepPassed || res.Merge != "" || res.Base != "" || res.Trees != nil || len(res.Modules) != 1 {
|
||||
t.Errorf("no base: %s %s", st.State, st.Result)
|
||||
}
|
||||
}
|
||||
|
||||
// 1677/AC1 on the real go: the unit level of a repository whose test reads MESH_CHECK_BESIDE fails loudly without the
|
||||
// sibling and passes with it; the sibling is a plain directory with no symlink in it; and a sibling that holds a
|
||||
// symlink fails the fetch naming it.
|
||||
func TestRealTestsReadTheSiblingBesideTheTree(t *testing.T) {
|
||||
f := newForge(t)
|
||||
files := map[string]string{}
|
||||
for _, n := range []string{"go.mod", "beside.go", "beside_test.go"} {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "repos", "beside", n))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files[n] = string(raw)
|
||||
}
|
||||
_, head := f.repo("o/r", files)
|
||||
_, sibling := f.repo("o/sibling", map[string]string{"data.txt": "from the sibling\n"})
|
||||
m, states := f.module(t)
|
||||
|
||||
without, _ := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head})
|
||||
if _, err := m.testToolRun(t, without.Workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res TestResult
|
||||
st := waitLong(t, states)
|
||||
json.Unmarshal(st.Result, &res)
|
||||
if st.State != delivery.StepPassed || res.Passed || len(res.Failed) != 1 || !strings.Contains(res.Failed[0].Output, "MESH_CHECK_BESIDE is not set") {
|
||||
t.Fatalf("without the sibling: %s %s", st.State, st.Result)
|
||||
}
|
||||
|
||||
with, st2 := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head,
|
||||
"beside": []any{map[string]any{"repository": "o/sibling", "commit": sibling}}})
|
||||
if st2.State != delivery.StepPassed || len(with.Beside) != 1 {
|
||||
t.Fatalf("with the sibling: %s %s", st2.State, st2.Result)
|
||||
}
|
||||
m.testToolRun(t, with.Workspace)
|
||||
res = TestResult{}
|
||||
st = waitLong(t, states)
|
||||
json.Unmarshal(st.Result, &res)
|
||||
if !res.Passed {
|
||||
t.Errorf("with the sibling the level failed: %s", st.Result)
|
||||
}
|
||||
if link := firstSymlink(filepath.Join(m.Work, with.Workspace, "beside")); link != "" {
|
||||
t.Errorf("the placed tree holds a symlink, %s", link)
|
||||
}
|
||||
|
||||
_, bad := f.repo("o/linked", map[string]string{"data.txt": "x\n"})
|
||||
work := filepath.Join(f.root, "work", "o/linked")
|
||||
if err := os.Symlink("/etc", filepath.Join(work, "etc-link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git(t, work, "add", ".")
|
||||
git(t, work, "commit", "-q", "-m", "link")
|
||||
bad = git(t, work, "rev-parse", "HEAD")
|
||||
git(t, work, "push", "-q", "-f", filepath.Join(f.root, "forge", "o/linked.git"), "HEAD:refs/heads/main")
|
||||
_, st3 := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head,
|
||||
"beside": []any{map[string]any{"repository": "o/linked", "commit": bad}}})
|
||||
if st3.State != delivery.StepFailed || !strings.Contains(st3.Reason, "etc-link") {
|
||||
t.Errorf("a sibling with a symlink: %s %s", st3.State, st3.Reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
const (
|
||||
baseSHA = "abcdef0123456789abcdef0123456789abcdef01"
|
||||
mergeSHA = "fedcba9876543210fedcba9876543210fedcba98"
|
||||
treeSHA = "1111111111111111111111111111111111111111"
|
||||
)
|
||||
|
||||
// previewRules answers the merge's commands as git does: the merge commit, the changed paths, the manifests, the trees.
|
||||
func previewRules(conflict bool) func(string) (forgekey.Ran, bool) {
|
||||
merged := false
|
||||
return func(line string) (forgekey.Ran, bool) {
|
||||
switch {
|
||||
case strings.Contains(line, "--is-shallow-repository"):
|
||||
return forgekey.Ran{Stdout: "true\n"}, true
|
||||
case strings.Contains(line, " merge --no-ff"):
|
||||
if conflict {
|
||||
return forgekey.Ran{Status: 1, Stderr: "CONFLICT (content): Merge conflict in a.go\n"}, true
|
||||
}
|
||||
merged = true
|
||||
return forgekey.Ran{}, true
|
||||
case strings.Contains(line, "diff --name-only --diff-filter=U"):
|
||||
return forgekey.Ran{Stdout: "a.go\nsub/b.go\n"}, true
|
||||
case strings.Contains(line, "diff --name-only --no-renames"):
|
||||
return forgekey.Ran{Stdout: "a.go\nmodules/m/main.go\n"}, true
|
||||
case strings.Contains(line, " rev-parse HEAD") && merged:
|
||||
return forgekey.Ran{Stdout: mergeSHA + "\n"}, true
|
||||
case strings.Contains(line, " ls-files ") && strings.Contains(line, "module.json"):
|
||||
return forgekey.Ran{Stdout: "modules/m/module.json\n"}, true
|
||||
case strings.Contains(line, " rev-parse "+mergeSHA+":modules/m"):
|
||||
return forgekey.Ran{Stdout: treeSHA + "\n"}, true
|
||||
}
|
||||
return forgekey.Ran{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// 1675/AC1: with a base the fetch merges the head onto it with the SDK's merge (the identity and flags of the check
|
||||
// merge, hooks off), and answers base, merge commit, changed paths and, per module directory, tree, manifest hash and lock hashes.
|
||||
func TestFetchWithABaseMergesAndAnswersThePreview(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n", rule: previewRules(false)}
|
||||
m, states := fetchModule(t, f)
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
// the workspace's module.json and go.sum are on disk once the fake checks the head out
|
||||
for i, a := range args {
|
||||
if a == "-C" && len(args) > i+1 && strings.Contains(strings.Join(args, " "), " checkout ") {
|
||||
os.MkdirAll(filepath.Join(args[i+1], "modules", "m"), 0o755)
|
||||
os.WriteFile(filepath.Join(args[i+1], "modules", "m", "module.json"), []byte("{}\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(args[i+1], "modules", "m", "go.sum"), []byte("sum\n"), 0o644)
|
||||
}
|
||||
}
|
||||
return f.run(ctx, env, name, args...)
|
||||
}
|
||||
args := map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": baseSHA}
|
||||
if _, err := fetchTool(t, m)(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
var res FetchResult
|
||||
if err := json.Unmarshal(st.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Base != baseSHA || res.Merge != mergeSHA || res.Commit != commitSHA || len(res.Conflicts) != 0 ||
|
||||
strings.Join(res.Changed, ",") != "a.go,modules/m/main.go" {
|
||||
t.Errorf("result = %s", st.Result)
|
||||
}
|
||||
mt, ok := res.Trees["modules/m"]
|
||||
if !ok || mt.Tree != treeSHA || mt.Manifest != sha256Hex([]byte("{}\n")) || mt.Locks["go.sum"] != sha256Hex([]byte("sum\n")) {
|
||||
t.Errorf("trees = %+v", res.Trees)
|
||||
}
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
for _, want := range []string{"fetch --no-tags --unshallow origin " + baseSHA, "checkout -q --detach " + baseSHA,
|
||||
"core.hooksPath=/dev/null", "merge --no-ff --no-edit --no-verify"} {
|
||||
if !strings.Contains(lines, want) {
|
||||
t.Errorf("git was not asked %q:\n%s", want, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1675/AC2: a conflict is an answer: the step ends passed, the files are named, no merge commit, nothing downloaded.
|
||||
func TestFetchWithAConflictEndsPassedAndNamesTheFiles(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n", rule: previewRules(true)}
|
||||
m, states := fetchModule(t, f)
|
||||
args := map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": baseSHA}
|
||||
if _, err := fetchTool(t, m)(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("a conflict ended the step %s: %s", st.State, st.Reason)
|
||||
}
|
||||
var res FetchResult
|
||||
json.Unmarshal(st.Result, &res)
|
||||
if res.Merge != "" || res.Base != baseSHA || strings.Join(res.Conflicts, ",") != "a.go,sub/b.go" || len(res.Changed) != 0 {
|
||||
t.Errorf("result = %s", st.Result)
|
||||
}
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
if !strings.Contains(lines, "merge --abort") || strings.Contains(lines, "mod download") {
|
||||
t.Errorf("the merge was not undone, or modules were downloaded:\n%s", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// 1675/AC3: no base is today's fetch: no merge, no extra command, and the answer carries none of the preview.
|
||||
func TestFetchWithoutABaseIsTodaysFetch(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n"}
|
||||
m, states := fetchModule(t, f)
|
||||
fetchTool(t, m)(fetchArgs)
|
||||
st := states.waitEnd(t)
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
if strings.Contains(lines, "merge") || strings.Contains(lines, "unshallow") {
|
||||
t.Errorf("a fetch without a base merged:\n%s", lines)
|
||||
}
|
||||
for _, key := range []string{"base", "merge", "conflicts", "changed", "trees", "beside"} {
|
||||
if strings.Contains(string(st.Result), `"`+key+`"`) {
|
||||
t.Errorf("the answer carries %q without a base: %s", key, st.Result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABaseThatIsNotACommitIsRefusedBeforeAnythingRuns(t *testing.T) {
|
||||
f := &fakeStep{}
|
||||
m, states := fetchModule(t, f)
|
||||
fetchTool(t, m)(map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": "main"})
|
||||
if st := states.waitEnd(t); st.State != delivery.StepFailed || len(f.lines()) != 0 {
|
||||
t.Errorf("the step ended %s after %d commands", st.State, len(f.lines()))
|
||||
}
|
||||
}
|
||||
|
||||
// 1677/AC1: each sibling is fetched into beside/<name> of the workspace, and the tests of the workspace are given
|
||||
// MESH_CHECK_BESIDE naming that directory, at the unit level; a workspace without siblings is given none.
|
||||
func TestSiblingsArePlacedBesideAndTheUnitLevelIsGivenTheirDirectory(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n"}
|
||||
m, states := fetchModule(t, f)
|
||||
args := map[string]any{"repository": "novox/mesh-controller", "commit": commitSHA, "beside": []any{
|
||||
map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA},
|
||||
map[string]any{"repository": "novox/mesh-host", "commit": commitSHA}}}
|
||||
if _, err := fetchTool(t, m)(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
var res FetchResult
|
||||
json.Unmarshal(st.Result, &res)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
if len(res.Beside) != 2 || res.Beside[0].Dir != "mesh-catalog" || res.Beside[1].Dir != "mesh-host" {
|
||||
t.Fatalf("result = %s", st.Result)
|
||||
}
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
for _, d := range []string{"beside/mesh-catalog", "beside/mesh-host"} {
|
||||
if !strings.Contains(lines, filepath.Join(m.Work, res.Workspace, d)+" init") && !strings.Contains(lines, "-C "+filepath.Join(m.Work, res.Workspace, d)) {
|
||||
t.Errorf("nothing was fetched into %s:\n%s", d, lines)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(lines, "remote add origin ssh://mesh-forge/novox/mesh-catalog.git") {
|
||||
t.Errorf("the sibling was not fetched over ssh:\n%s", lines)
|
||||
}
|
||||
|
||||
calls := &[]cmdRecord{}
|
||||
m.Run = capturedRunner(t, "failing", calls)
|
||||
if err := os.MkdirAll(filepath.Join(m.Work, res.Workspace, "tree"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.testToolRun(t, res.Workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
states.waitEnd(t)
|
||||
want := "MESH_CHECK_BESIDE=" + filepath.Join(m.Work, res.Workspace, "beside")
|
||||
if !envHas(*calls, "go", want) {
|
||||
t.Errorf("no go command was given %s", want)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) testToolRun(t *testing.T, ws string) (any, error) {
|
||||
t.Helper()
|
||||
for _, tool := range m.Tools(m.StepTools()) {
|
||||
if tool.Name == "build_go_test" {
|
||||
return tool.Run(map[string]any{"workspace": ws, "level": "unit"})
|
||||
}
|
||||
}
|
||||
t.Fatal("no tool build_go_test")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func envHas(calls []cmdRecord, name, want string) bool {
|
||||
for _, c := range calls {
|
||||
if strings.HasPrefix(c.line, name+" ") {
|
||||
for _, e := range c.env {
|
||||
if e == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAWorkspaceWithoutSiblingsIsGivenNoBesideVariable(t *testing.T) {
|
||||
m, states, ws, calls := workspaceFor(t, "failing")
|
||||
runUnit(t, m, states, ws)
|
||||
for _, c := range *calls {
|
||||
for _, e := range c.env {
|
||||
if strings.HasPrefix(e, "MESH_CHECK_BESIDE=") {
|
||||
t.Errorf("%s was given %s", c.line, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiblingsThatAreNotRepositoriesOrCommitsAreRefused(t *testing.T) {
|
||||
for name, beside := range map[string]any{
|
||||
"not a list": "x",
|
||||
"bad repository": []any{map[string]any{"repository": "../x/y", "commit": baseSHA}},
|
||||
"bad commit": []any{map[string]any{"repository": "novox/a", "commit": "main"}},
|
||||
"twice": []any{map[string]any{"repository": "novox/a", "commit": baseSHA}, map[string]any{"repository": "other/a", "commit": baseSHA}},
|
||||
} {
|
||||
f := &fakeStep{}
|
||||
m, states := fetchModule(t, f)
|
||||
fetchTool(t, m)(map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "beside": beside})
|
||||
if st := states.waitEnd(t); st.State != delivery.StepFailed || len(f.lines()) != 0 {
|
||||
t.Errorf("%s: the step ended %s after %d commands", name, st.State, len(f.lines()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1288/AC5 (the checker's contract): an import path runs only in the go.mod directory whose module holds it; a
|
||||
// relative pattern runs in every directory; no pattern is ./... everywhere.
|
||||
func TestPackagesRunOnlyInTheModuleThatHoldsThem(t *testing.T) {
|
||||
tree := t.TempDir()
|
||||
for dir, mod := range map[string]string{".": "example.org/root", "tools": "example.org/root/tools", "other": "example.org/other"} {
|
||||
os.MkdirAll(filepath.Join(tree, dir), 0o755)
|
||||
os.WriteFile(filepath.Join(tree, dir, "go.mod"), []byte("module "+mod+"\n\ngo 1.26\n"), 0o644)
|
||||
}
|
||||
dirs := []string{".", "other", "tools"}
|
||||
got, err := packagesByDir(tree, dirs, []string{"example.org/root/a", "example.org/root/tools/b", "example.org/other"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{".": "example.org/root/a", "tools": "example.org/root/tools/b", "other": "example.org/other"}
|
||||
for d, w := range want {
|
||||
if strings.Join(got[d], ",") != w {
|
||||
t.Errorf("%s runs %v, want %s", d, got[d], w)
|
||||
}
|
||||
}
|
||||
got, _ = packagesByDir(tree, dirs, []string{"./x/..."})
|
||||
if len(got["."]) != 1 || len(got["tools"]) != 1 || len(got["other"]) != 1 {
|
||||
t.Errorf("a relative pattern = %v", got)
|
||||
}
|
||||
got, _ = packagesByDir(tree, dirs, nil)
|
||||
if got["tools"][0] != "./..." {
|
||||
t.Errorf("no pattern = %v", got)
|
||||
}
|
||||
if _, err := packagesByDir(tree, dirs, []string{"example.org/nowhere/x"}); err == nil || !strings.Contains(err.Error(), "example.org/nowhere/x") {
|
||||
t.Errorf("a package no module holds: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
|
||||
|
||||
// 1677/AC (integration level): the integration level's go test is given MESH_CHECK_BESIDE when the workspace has siblings
|
||||
// beside it, with the store's and the bus's addresses, and is given none when it has not.
|
||||
func TestTheIntegrationLevelIsGivenTheBesideDirectoryOnlyWhenSiblingsWerePlaced(t *testing.T) {
|
||||
for _, siblings := range []bool{true, false} {
|
||||
f := newIntegFake(t, "slow")
|
||||
m, states := integModule(t, f)
|
||||
beside := filepath.Join(m.Work, "ws1", "beside")
|
||||
if siblings {
|
||||
if err := os.MkdirAll(filepath.Join(beside, "mesh-catalog"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, st := runLevelTool(t, m, states, "integration"); st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
tested := 0
|
||||
for _, c := range f.calls {
|
||||
if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") {
|
||||
continue
|
||||
}
|
||||
tested++
|
||||
got := ""
|
||||
hasNATS := false
|
||||
for _, e := range c.env {
|
||||
if strings.HasPrefix(e, "MESH_CHECK_BESIDE=") {
|
||||
got = strings.TrimPrefix(e, "MESH_CHECK_BESIDE=")
|
||||
}
|
||||
hasNATS = hasNATS || strings.HasPrefix(e, "MESH_TEST_NATS=")
|
||||
}
|
||||
want := ""
|
||||
if siblings {
|
||||
want = beside
|
||||
}
|
||||
if got != want || !hasNATS {
|
||||
t.Errorf("siblings %v: %s was given MESH_CHECK_BESIDE=%q (want %q), MESH_TEST_NATS present %v", siblings, c.line, got, want, hasNATS)
|
||||
}
|
||||
}
|
||||
if tested == 0 {
|
||||
t.Errorf("siblings %v: no integration go test ran", siblings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const PostgresMajor = 18
|
||||
|
||||
// RecipeSteps is the recipe's own version: changed whenever the steps of fetch, test or build change in a way
|
||||
// that can change what they produce (ADR 0405 §7). It is one of the three values the recipe version hashes.
|
||||
const RecipeSteps = "1"
|
||||
const RecipeSteps = "2"
|
||||
|
||||
// natsModule is the library the integration level's bus is started from (ADR 0470 §2).
|
||||
const natsModule = "github.com/nats-io/nats-server/v2"
|
||||
|
||||
@@ -65,13 +65,23 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
|
||||
}
|
||||
switch level {
|
||||
case LevelUnit:
|
||||
return m.runLevel(ctx, tree, levelOpts{level: LevelUnit, packages: packages, bounds: Bounds{Run: m.unitBound()}})
|
||||
return m.runLevel(ctx, tree, levelOpts{level: LevelUnit, packages: packages, bounds: Bounds{Run: m.unitBound()}, env: m.besideEnv(workspace)})
|
||||
case LevelIntegration:
|
||||
return m.integration(ctx, workspace, tree, packages)
|
||||
}
|
||||
return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
|
||||
}
|
||||
|
||||
// besideEnv is MESH_CHECK_BESIDE, naming the directory build_go_fetch placed the workspace's siblings in (ADR 0484
|
||||
// clause 4), when it placed any; the tests that read other repositories look there (mesh-sdk go/internal/beside).
|
||||
func (m *Module) besideEnv(workspace string) []string {
|
||||
dir := m.besideDir(workspace)
|
||||
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
|
||||
return nil
|
||||
}
|
||||
return []string{"MESH_CHECK_BESIDE=" + dir}
|
||||
}
|
||||
|
||||
func packagesArg(v any) ([]string, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
@@ -115,12 +125,16 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
|
||||
return nil, err
|
||||
}
|
||||
res := &TestResult{Level: o.level, Packages: []PackageResult{}, Setup: o.setup.Seconds(), Store: o.store, Bus: o.bus}
|
||||
packages := o.packages
|
||||
if len(packages) == 0 {
|
||||
packages = []string{"./..."}
|
||||
byDir, err := packagesByDir(tree, dirs, o.packages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
began := m.clock()
|
||||
for _, d := range dirs {
|
||||
packages := byDir[d]
|
||||
if len(packages) == 0 {
|
||||
continue // no package asked for lies in this module
|
||||
}
|
||||
args := []string{"-C", filepath.Join(tree, d), "test", "-json", "-count=1", "-timeout", testTimeout}
|
||||
if len(o.tags) > 0 {
|
||||
args = append(args, "-tags", strings.Join(o.tags, ","))
|
||||
@@ -141,3 +155,50 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
|
||||
res.judge(o.bounds)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// packagesByDir says which patterns run in which go.mod directory. A relative pattern (./...) runs in every directory, as
|
||||
// it always did; an import path, as build_go_digest keys its packages, runs only in the directory whose module holds it
|
||||
// (the longest module path that is the path or its prefix), as another module's path would fail the step. No pattern
|
||||
// means ./... everywhere. An import path no module of the tree holds is an error that names it.
|
||||
func packagesByDir(tree string, dirs, patterns []string) (map[string][]string, error) {
|
||||
out := map[string][]string{}
|
||||
if len(patterns) == 0 {
|
||||
patterns = []string{"./..."}
|
||||
}
|
||||
modules := map[string]string{}
|
||||
for _, d := range dirs {
|
||||
if raw, err := os.ReadFile(filepath.Join(tree, d, "go.mod")); err == nil {
|
||||
modules[d] = modulePath(string(raw))
|
||||
}
|
||||
}
|
||||
for _, p := range patterns {
|
||||
if strings.HasPrefix(p, "./") || p == "." {
|
||||
for _, d := range dirs {
|
||||
out[d] = append(out[d], p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
owner, best := "", -1
|
||||
for _, d := range dirs {
|
||||
mp := modules[d]
|
||||
if mp != "" && (p == mp || strings.HasPrefix(p, mp+"/")) && len(mp) > best {
|
||||
owner, best = d, len(mp)
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return nil, fmt.Errorf("no module of the workspace holds the package %s", p)
|
||||
}
|
||||
out[owner] = append(out[owner], p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// modulePath is the module path a go.mod declares, "" when it declares none.
|
||||
func modulePath(gomod string) string {
|
||||
for _, l := range strings.Split(gomod, "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "module" {
|
||||
return strings.Trim(f[1], `"`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
package beside
|
||||
@@ -0,0 +1,22 @@
|
||||
package beside
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A test of a repository that reads another one, as the controller's, the node-engine's, the SDK's and the catalogue's do.
|
||||
func TestReadsTheSiblingBesideIt(t *testing.T) {
|
||||
root := os.Getenv("MESH_CHECK_BESIDE")
|
||||
if root == "" {
|
||||
t.Fatal("the sibling is not beside this check: MESH_CHECK_BESIDE is not set")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "sibling", "data.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "from the sibling\n" {
|
||||
t.Fatalf("data.txt = %q", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module fixture/beside
|
||||
|
||||
go 1.26
|
||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3 h1:OKxU64kHN1yQ/GOa6LwoUwVmiQ74d83WMSZJRFPVm9c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5 h1:1BiPKRPTYpgex8rSHWT9az0BteOafGWRDzd/Ba6GAcA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -36,6 +36,8 @@ const sdkPackage = "@novox/mesh-sdk"
|
||||
// WorkspaceAge is how long a workspace is kept.
|
||||
const WorkspaceAge = 24 * time.Hour
|
||||
|
||||
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
var repositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
|
||||
// FetchResult is what a passed fetch step says.
|
||||
@@ -48,6 +50,15 @@ type FetchResult struct {
|
||||
// Refused are the bundles whose packages were not installed, each with why (a module naming packages beyond the SDK
|
||||
// with no package-lock.json). The fetch passes; a build of such a bundle fails naming it (issue 1459).
|
||||
Refused []Refusal `json:"refused"`
|
||||
// The merge preview (ADR 0484 clause 3), only when the call gave a base, as build_go_fetch answers it: the trunk
|
||||
// commit, the merge commit of the head onto it, the paths the merge changes and the tree of each module directory.
|
||||
// A head that does not merge cleanly is an answer: Merge is empty, Conflicts names the files, and the workspace is
|
||||
// left at the trunk commit with nothing installed.
|
||||
Base string `json:"base,omitempty"`
|
||||
Merge string `json:"merge,omitempty"`
|
||||
Conflicts []string `json:"conflicts,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
|
||||
}
|
||||
|
||||
// Refusal is a module a fetch did not install the packages of.
|
||||
@@ -68,6 +79,7 @@ func (m *Module) fetchTool() StepTool {
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
|
||||
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
|
||||
"ref": map[string]any{"type": "string", "description": "refs/pull/<n>/head when only a pull request's head carries the commit"},
|
||||
"base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"},
|
||||
},
|
||||
"required": []string{"repository", "commit"},
|
||||
},
|
||||
@@ -80,6 +92,10 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
repository, _ := args["repository"].(string)
|
||||
commit, _ := args["commit"].(string)
|
||||
ref, _ := args["ref"].(string)
|
||||
base, _ := args["base"].(string)
|
||||
if base != "" && !commitOK.MatchString(base) {
|
||||
return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base)
|
||||
}
|
||||
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
|
||||
return nil, fmt.Errorf("%q is not a repository, <owner>/<name>", repository)
|
||||
}
|
||||
@@ -94,15 +110,23 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
gitRun := func(ctx context.Context, env []string, name string, a ...string) forgekey.Ran {
|
||||
return m.Run(ctx, append(m.baseEnv(), env...), name, a...)
|
||||
}
|
||||
remote := "ssh://" + ForgeAlias + "/" + repository + ".git"
|
||||
if err := buildstep.FetchCommit(ctx, gitRun, tree, remote, commit, ref); err != nil {
|
||||
remote := m.remote(repository)
|
||||
if err := buildstep.FetchCommit(ctx, gitRun, tree, remote, commit, ref, m.FetchOptions...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := FetchResult{Workspace: id, Commit: commit, Modules: []string{}, Installed: []string{}, Refused: []Refusal{}}
|
||||
if base != "" {
|
||||
if err := m.merge(ctx, gitRun, &res, tree, remote, base, commit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Merge == "" {
|
||||
return res, nil // a conflict: the tree is at the trunk, nothing of the head is installed
|
||||
}
|
||||
}
|
||||
dirs, err := m.typeScriptModules(ctx, tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := FetchResult{Workspace: id, Commit: commit, Modules: []string{}, Installed: []string{}, Refused: []Refusal{}}
|
||||
for _, d := range dirs {
|
||||
res.Modules = append(res.Modules, d)
|
||||
did, refused, err := m.installOwn(ctx, root, filepath.Join(tree, filepath.FromSlash(d)), d)
|
||||
|
||||
@@ -48,7 +48,7 @@ func (f *fakeMachine) run(_ context.Context, env []string, name string, args ...
|
||||
}
|
||||
} else if name == "git" {
|
||||
for _, a := range args {
|
||||
if slices.Contains([]string{"init", "remote", "fetch", "checkout", "rev-parse", "ls-files"}, a) {
|
||||
if slices.Contains([]string{"init", "remote", "fetch", "checkout", "rev-parse", "ls-files", "merge", "diff"}, a) {
|
||||
key = "git " + a
|
||||
break
|
||||
}
|
||||
|
||||
@@ -31,9 +31,13 @@ type Module struct {
|
||||
Toolchain *Toolchain
|
||||
// Work is the work directory, outside the home: a workspace is <Work>/<step id>/tree. Run runs the commands of a
|
||||
// step (a fake in a test). Clock is the time the steps are timed by.
|
||||
Work string
|
||||
Run forgekey.Runner
|
||||
Clock func() time.Time
|
||||
Work string
|
||||
Run forgekey.Runner
|
||||
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
|
||||
// the SDK's fetch and merge. A test points them at a local repository (buildstep.AllowFileRemote).
|
||||
RemoteBase string
|
||||
FetchOptions []buildstep.Option
|
||||
Clock func() time.Time
|
||||
// Toolchains and Steps are the Put of the states of those names.
|
||||
Toolchains, Steps buildstep.Put
|
||||
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
// The merge preview of a fetch (novox/hq ADR 0484 clause 3; issue 1676), as build_go_fetch answers it. The merge itself
|
||||
// is the SDK's (buildstep.MergeInto), the one every build module's fetch step makes.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// remote is the address a repository is fetched from: the forge over ssh as the account, or RemoteBase when a test set it.
|
||||
func (m *Module) remote(repository string) string {
|
||||
base := m.RemoteBase
|
||||
if base == "" {
|
||||
base = "ssh://" + ForgeAlias + "/"
|
||||
}
|
||||
return base + repository + ".git"
|
||||
}
|
||||
|
||||
// merge makes the merge preview of the head onto base and writes the answer into res.
|
||||
func (m *Module) merge(ctx context.Context, run forgekey.Runner, res *FetchResult, tree, remote, base, commit string) error {
|
||||
mr, err := buildstep.MergeInto(ctx, run, tree, remote, base, commit, m.FetchOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Base = mr.Base
|
||||
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
|
||||
if res.Merge == "" {
|
||||
return nil
|
||||
}
|
||||
res.Trees, err = buildstep.ModuleTrees(ctx, m.Run, m.baseEnv(), tree, res.Merge)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// The fetch step's merge on real git repositories (issue 1676); preview_test.go proves the commands with a fake runner.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
func git(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func TestRealFetchMergesAConflictAndAPlainFetch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bare, work := filepath.Join(root, "forge", "o", "r.git"), filepath.Join(root, "work")
|
||||
os.MkdirAll(bare, 0o755)
|
||||
os.MkdirAll(work, 0o755)
|
||||
git(t, bare, "init", "-q", "--bare")
|
||||
git(t, work, "init", "-q", "-b", "main")
|
||||
files := typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6"}}`)
|
||||
files["a.txt"] = "one\ntwo\nthree\n"
|
||||
for n, c := range files {
|
||||
write(t, filepath.Join(work, n), c, 0o644)
|
||||
}
|
||||
git(t, work, "add", ".")
|
||||
git(t, work, "commit", "-q", "-m", "fork")
|
||||
fork := git(t, work, "rev-parse", "HEAD")
|
||||
change := func(from, ref string, f map[string]string) string {
|
||||
git(t, work, "checkout", "-q", "-B", "tmp", from)
|
||||
for n, c := range f {
|
||||
write(t, filepath.Join(work, n), c, 0o644)
|
||||
}
|
||||
git(t, work, "add", ".")
|
||||
git(t, work, "commit", "-q", "-m", "c")
|
||||
git(t, work, "push", "-q", "-f", bare, "HEAD:"+ref)
|
||||
return git(t, work, "rev-parse", "HEAD")
|
||||
}
|
||||
trunk := change(fork, "refs/heads/main", map[string]string{"a.txt": "one\ntwo\nTRUNK\n"})
|
||||
clean := change(fork, "refs/pull/1/head", map[string]string{"modules/m/extra.ts": "export {}\n"})
|
||||
clash := change(fork, "refs/pull/2/head", map[string]string{"a.txt": "one\ntwo\nHEAD\n"})
|
||||
|
||||
m, _, _ := newMachine(t)
|
||||
m.Run = forgekey.ExecRunner
|
||||
m.RemoteBase = "file://" + filepath.Join(root, "forge") + "/"
|
||||
m.FetchOptions = []buildstep.Option{buildstep.AllowFileRemote()}
|
||||
|
||||
out, err := m.fetch(context.Background(), map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head", "base": trunk})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := out.(FetchResult)
|
||||
tree := filepath.Join(m.Work, r.Workspace, "tree")
|
||||
if r.Merge == "" || strings.Join(r.Changed, ",") != "modules/m/extra.ts" || len(r.Conflicts) != 0 || len(r.Modules) != 1 {
|
||||
t.Fatalf("clean: %+v", r)
|
||||
}
|
||||
if want := git(t, tree, "rev-parse", r.Merge+":modules/m"); r.Trees["modules/m"].Tree != want {
|
||||
t.Errorf("tree = %s, want %s", r.Trees["modules/m"].Tree, want)
|
||||
}
|
||||
if got := git(t, tree, "rev-parse", "HEAD"); got != r.Merge {
|
||||
t.Errorf("the workspace is at %s, want the merge %s", got, r.Merge)
|
||||
}
|
||||
|
||||
out, err = m.fetch(context.Background(), map[string]any{"repository": "o/r", "commit": clash, "ref": "refs/pull/2/head", "base": trunk})
|
||||
if err != nil {
|
||||
t.Fatalf("a conflict failed the step: %v", err)
|
||||
}
|
||||
r = out.(FetchResult)
|
||||
if r.Merge != "" || strings.Join(r.Conflicts, ",") != "a.txt" {
|
||||
t.Fatalf("conflict: %+v", r)
|
||||
}
|
||||
if got := git(t, filepath.Join(m.Work, r.Workspace, "tree"), "rev-parse", "HEAD"); got != trunk {
|
||||
t.Errorf("after a conflict the workspace is at %s, want the trunk %s", got, trunk)
|
||||
}
|
||||
|
||||
out, err = m.fetch(context.Background(), map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head"})
|
||||
if err != nil || out.(FetchResult).Merge != "" || out.(FetchResult).Trees != nil {
|
||||
t.Errorf("no base: %+v %v", out, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
const baseSHA = "abcdef0123456789abcdef0123456789abcdef01"
|
||||
|
||||
func commandLines(f *fakeMachine) string {
|
||||
var out []string
|
||||
for _, c := range f.calls {
|
||||
out = append(out, joined(c))
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// 1676/AC1: with a base the fetch merges the head onto it through the SDK's merge (the check merge's identity and flags,
|
||||
// hooks off), and answers the base, the merge commit, the changed paths and the tree, manifest and locks of each module
|
||||
// directory; the TypeScript modules are installed from the merged tree.
|
||||
func TestFetchWithABaseMergesAndAnswersThePreview(t *testing.T) {
|
||||
m, f := fetchMachine(t, typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6"}}`), nil)
|
||||
f.answer["git diff"] = forgekey.Ran{Stdout: "modules/m/package.json\n"}
|
||||
args := map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA, "base": baseSHA}
|
||||
out, err := m.fetch(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := out.(FetchResult)
|
||||
if r.Base != baseSHA || r.Merge != commitSHA || strings.Join(r.Changed, ",") != "modules/m/package.json" || len(r.Conflicts) != 0 {
|
||||
t.Errorf("result = %+v", r)
|
||||
}
|
||||
if mt, ok := r.Trees["modules/m"]; !ok || mt.Tree != commitSHA || mt.Manifest == "" {
|
||||
t.Errorf("trees = %+v", r.Trees)
|
||||
}
|
||||
if len(r.Modules) != 1 || r.Modules[0] != "modules/m" {
|
||||
t.Errorf("modules = %v", r.Modules)
|
||||
}
|
||||
for _, want := range []string{"fetch --no-tags origin " + baseSHA, "core.hooksPath=/dev/null", "merge --no-ff --no-edit --no-verify"} {
|
||||
if !strings.Contains(commandLines(f), want) {
|
||||
t.Errorf("git was not asked %q:\n%s", want, commandLines(f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1676/AC2: a conflict is an answer: the step passes with the files named, no merge commit, and nothing installed.
|
||||
func TestFetchWithAConflictAnswersTheFilesAndInstallsNothing(t *testing.T) {
|
||||
files := typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6","lodash":"^4"}}`)
|
||||
files["modules/m/package-lock.json"] = `{"lockfileVersion":3}`
|
||||
m, f := fetchMachine(t, files, nil)
|
||||
f.answer["git merge"] = forgekey.Ran{Status: 1, Stderr: "CONFLICT\n"}
|
||||
f.answer["git diff"] = forgekey.Ran{Stdout: "a.ts\nsub/b.ts\n"}
|
||||
out, err := m.fetch(context.Background(), map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA, "base": baseSHA})
|
||||
if err != nil {
|
||||
t.Fatalf("a conflict failed the step: %v", err)
|
||||
}
|
||||
r := out.(FetchResult)
|
||||
if r.Merge != "" || strings.Join(r.Conflicts, ",") != "a.ts,sub/b.ts" || len(r.Changed) != 0 || len(npmCalls(f)) != 0 {
|
||||
t.Errorf("result = %+v, npm calls %d", r, len(npmCalls(f)))
|
||||
}
|
||||
if !strings.Contains(commandLines(f), "merge --abort") {
|
||||
t.Errorf("the merge was not undone:\n%s", commandLines(f))
|
||||
}
|
||||
}
|
||||
|
||||
// 1676/AC3: no base is today's fetch: no merge command, and none of the preview in the answer.
|
||||
func TestFetchWithoutABaseMakesNoMerge(t *testing.T) {
|
||||
m, f := fetchMachine(t, typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6"}}`), nil)
|
||||
out, err := m.fetch(context.Background(), fetchArgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := out.(FetchResult)
|
||||
if strings.Contains(commandLines(f), "merge") || strings.Contains(commandLines(f), "unshallow") || r.Base != "" || r.Merge != "" || r.Trees != nil {
|
||||
t.Errorf("a fetch without a base merged: %+v\n%s", r, commandLines(f))
|
||||
}
|
||||
}
|
||||
|
||||
func TestABaseThatIsNotACommitIsRefusedBeforeAnythingRuns(t *testing.T) {
|
||||
m, f := fetchMachine(t, nil, nil)
|
||||
if _, err := m.fetch(context.Background(), map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA, "base": "main"}); err == nil || len(f.calls) != 0 {
|
||||
t.Errorf("err = %v after %d commands", err, len(f.calls))
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@ module meshbuildnode
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3 h1:OKxU64kHN1yQ/GOa6LwoUwVmiQ74d83WMSZJRFPVm9c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5 h1:1BiPKRPTYpgex8rSHWT9az0BteOafGWRDzd/Ba6GAcA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
|
||||
@@ -380,18 +380,19 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
// A group is not asked while a member's required status is not a success at its head (issue 1275): the group
|
||||
// says which, and is asked once every member may merge.
|
||||
var holdMember, holdSays string
|
||||
var holdOwn bool
|
||||
var readErr error
|
||||
for _, m := range reads {
|
||||
if gid == "" {
|
||||
break
|
||||
}
|
||||
says, _, err := h.notASuccess(m)
|
||||
says, ownOnly, err := h.notASuccess(m)
|
||||
if err != nil {
|
||||
readErr = err
|
||||
break
|
||||
}
|
||||
if says != "" {
|
||||
holdMember, holdSays = m.id, says
|
||||
holdMember, holdSays, holdOwn = m.id, says, ownOnly
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -421,7 +422,18 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
if readErr != nil || holdSays != "" {
|
||||
stale()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var own func()
|
||||
// A member's own repository check pending or missing (a sibling's new head put it out of date, issue 1792) is
|
||||
// asked again here, once for the head: nothing else asks it, and the composed check sets no repository check.
|
||||
if d := h.deliveries[holdMember]; holdOwn && d != nil && d.Ready != nil {
|
||||
own = h.askOwnAfterRefusal(d)
|
||||
}
|
||||
defer func() {
|
||||
h.mu.Unlock()
|
||||
if own != nil {
|
||||
own()
|
||||
}
|
||||
}()
|
||||
undo()
|
||||
if g := h.groups[gid]; g != nil && holdSays != "" {
|
||||
if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Says != holdSays || g.Hold.Member != holdMember {
|
||||
@@ -634,6 +646,7 @@ func (h *Holder) answered(key string, ans sdk.Answer) []func() {
|
||||
for _, d := range covered {
|
||||
r := d.Ready
|
||||
r.Warrant = ans.Words
|
||||
r.Repeats = 0 // the operator chose: whatever the tool refused before is asked afresh (issue 1106)
|
||||
h.mergeAgainNow(d) // the operator chose: the merge is asked at once
|
||||
switch ans.Option {
|
||||
case OptionApprove:
|
||||
|
||||
@@ -519,30 +519,29 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
passed := b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
|
||||
|
||||
// The ask is written into the gate's bucket asks within seconds of the verdict's event, with no label, comment or mark
|
||||
// written by anyone, and nothing is asked of a router.
|
||||
// No review names the head, so nothing is asked (ADR 0496, issue 1803): the delivery waits for a review.
|
||||
rt.until("the delivery to wait for a review", 5*time.Second, func() bool {
|
||||
e, err := b.kvs["pulls"].Get(ctx, "novox.app.7")
|
||||
var k PullKey
|
||||
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k.Verdict == "pass"
|
||||
})
|
||||
time.Sleep(time.Second)
|
||||
if key := onlyAsk(b); key != "" {
|
||||
t.Fatalf("a head with no review was asked: %s", key)
|
||||
}
|
||||
// The description is edited to add the review record (issue 1079): the forge module's edit event carries it, and the
|
||||
// ask is written into the gate's bucket asks within seconds, with no label, comment or mark written by anyone, and
|
||||
// nothing is asked of a router.
|
||||
edit := time.Now()
|
||||
b.publish(t, "mesh.mod.gitea.event.forge.pull-request.edited", map[string]any{"owner": "novox", "repo": "app", "number": 7,
|
||||
"title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head})
|
||||
_ = passed
|
||||
var key string
|
||||
rt.until("the ask to be written", 5*time.Second, func() bool { key = onlyAsk(b); return key != "" })
|
||||
ask := askAt(t, b, key)
|
||||
t.Logf("the ask was written %s after the verdict's event", time.Since(passed))
|
||||
if !strings.Contains(ask.Ask.Explanation, "not reviewed at this head") || ask.Ask.ID != sdk.AskID(key) {
|
||||
t.Fatalf("the first ask does not say the record is missing: %s", ask.Ask.Explanation)
|
||||
}
|
||||
// The description is edited to add the review record (issue 1079): the forge module's edit event carries it. The unanswered
|
||||
// ask is taken away and written again, once, with the record read (the heads are the same, so is the key).
|
||||
b.publish(t, "mesh.mod.gitea.event.forge.pull-request.edited", map[string]any{"owner": "novox", "repo": "app", "number": 7,
|
||||
"title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head})
|
||||
rt.until("the ask to be written again with the record", 5*time.Second, func() bool {
|
||||
a, ok := askNow(b, key) // the key may be absent for an instant while the ask is taken away and written again
|
||||
return ok && strings.Contains(a.Ask.Explanation, "reviewed by lead on shanks")
|
||||
})
|
||||
// The ask is overwritten, never taken away: the key is there at every revision, and the second asking has an id of its own
|
||||
// (review round 1, G2), which the router would take as new.
|
||||
if h, err := b.kvs["asks"].History(ctx, key); err != nil || len(h) != 2 || h[1].Operation() != jetstream.KeyValuePut {
|
||||
t.Fatalf("the ask's history is %d revisions (%v); wanted written and written again", len(h), err)
|
||||
}
|
||||
if again := askAt(t, b, key); again.Asking != 2 || again.Ask.ID != sdk.AskingID(key, 2) || again.Ask.ID == ask.Ask.ID {
|
||||
t.Fatalf("the second asking is %d under %s, the first was %s", again.Asking, again.Ask.ID, ask.Ask.ID)
|
||||
t.Logf("the ask was written %s after the edit's event", time.Since(edit))
|
||||
if !strings.Contains(ask.Ask.Explanation, "reviewed by lead on shanks") || ask.Ask.ID != sdk.AskID(key) {
|
||||
t.Fatalf("the ask does not say the record: %s", ask.Ask.Explanation)
|
||||
}
|
||||
if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 {
|
||||
t.Fatalf("merged before the Approve")
|
||||
|
||||
@@ -190,7 +190,7 @@ func (h *Holder) deliveryOf(repository string, number int, commit string) *check
|
||||
c.Check = &v
|
||||
}
|
||||
if g := h.groups[found.Group]; g != nil {
|
||||
state, why := GroupState(g, h.membersOf(g))
|
||||
state, why, _ := h.groupState(g, h.membersOf(g))
|
||||
c.groupSaid = state + ": " + why
|
||||
if g.Check != nil && g.Check.Verdict != "" {
|
||||
c.groupSaid += "; composed check " + g.Check.Verdict + " — " + g.Check.Summary
|
||||
|
||||
@@ -131,6 +131,9 @@ func statusOf(d *Delivery) (string, string) {
|
||||
case Proposed, Checked:
|
||||
return "pending", "checking"
|
||||
case Ready:
|
||||
if d.holdsForReview() {
|
||||
return "pending", clip("ready, "+d.Ready.Waits, 140) // novox/hq ADR 0496: no Approve is asked before a review
|
||||
}
|
||||
return "success", "ready: it delivers once merged"
|
||||
case Rejected:
|
||||
return "failure", clip("rejected: "+why, 140)
|
||||
@@ -154,6 +157,9 @@ func statusOf(d *Delivery) (string, string) {
|
||||
}
|
||||
return "failure", clip("failed: "+why, 140)
|
||||
case Superseded:
|
||||
if d.coveredStop() {
|
||||
return "success", clip(d.coveredStopSaid(), 140) // its walk's taker delivered it (issue 1786)
|
||||
}
|
||||
return "warning", clip("superseded: "+why, 140)
|
||||
case Stopped:
|
||||
if d.coveredStop() {
|
||||
|
||||
@@ -158,11 +158,11 @@ func (*askedAgainError) Error() string {
|
||||
return "the forge refused gitea_merge_pull_request: novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. The check of head cccccccccccc merged into trunk bbbbbbbbbbbb was asked; took 3s. Ask again at cccccccccccc once both statuses pass. Nothing was merged"
|
||||
}
|
||||
|
||||
// Issue 1022 (ADR 0421 §5): the review record is read and put in the ask; it is never a gate.
|
||||
func TestTheAskSaysFirstWhenThePullRequestIsNotReviewedAtThisHead(t *testing.T) {
|
||||
for _, c := range []struct{ name, body, want string }{
|
||||
{"no line", "Fixes issue 7.", "not reviewed at this head"},
|
||||
{"another head", "Reviewed-by: lead@shanks at " + pushed, "the Reviewed-by line names " + pushed[:12]},
|
||||
// Issue 1022 (ADR 0421 §5), as ADR 0496 changed it (issue 1803): the review record is read; a head not reviewed is not asked.
|
||||
func TestAHeadNotReviewedAtThisHeadIsNotAsked(t *testing.T) {
|
||||
for _, c := range []struct{ name, body string }{
|
||||
{"no line", "Fixes issue 7."},
|
||||
{"another head", "Reviewed-by: lead@shanks at " + pushed},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
@@ -171,13 +171,8 @@ func TestTheAskSaysFirstWhenThePullRequestIsNotReviewedAtThisHead(t *testing.T)
|
||||
p.Body = c.body
|
||||
w.h.PullUpdated(p)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("an unreviewed pull request must still be asked: %d", w.op.count())
|
||||
}
|
||||
a := w.op.ask(w.op.open()[0])
|
||||
first := strings.SplitN(a.Explanation, "\n", 2)[0]
|
||||
if !strings.Contains(first, "not reviewed at this head") || !strings.Contains(a.Explanation, c.want) {
|
||||
t.Fatalf("the ask does not say first that it is not reviewed at this head:\n%s", a.Explanation)
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("an unreviewed pull request was asked: %d", w.op.count())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -601,10 +601,11 @@ type world struct {
|
||||
source *fakeSource
|
||||
owed *fakeOwed
|
||||
// asked is the key of the ask a test answered.
|
||||
asked string
|
||||
now time.Time
|
||||
said []map[string]any
|
||||
mu sync.Mutex
|
||||
asked string
|
||||
now time.Time
|
||||
logbuf strings.Builder
|
||||
said []map[string]any
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newWorld(t *testing.T) *world {
|
||||
@@ -615,7 +616,7 @@ func newWorldOver(t *testing.T, store *memStore, ctl *fakeController, forge *fak
|
||||
w := &world{t: t, store: store, ctl: ctl, forge: forge, now: now}
|
||||
w.h = &Holder{Store: store, Controller: ctl, Forge: forge,
|
||||
Now: func() time.Time { w.mu.Lock(); defer w.mu.Unlock(); return w.now },
|
||||
Logf: func(format string, a ...any) { t.Logf(format, a...) },
|
||||
Logf: func(format string, a ...any) { t.Logf(format, a...); fmt.Fprintf(&w.logbuf, format+"\n", a...) },
|
||||
Emit: func(event string, body any) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -682,7 +683,9 @@ func (w *world) state(id string) State { return w.delivery(id).State }
|
||||
func pr(repo string, number int, sha, branch string, paths ...string) PullEvent {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
return PullEvent{Owner: owner, Repo: name, Number: number, Base: "main", Head: branch, HeadSHA: sha,
|
||||
HTMLURL: fmt.Sprintf("https://forge.invalid/%s/pulls/%d", repo, number), Paths: paths}
|
||||
HTMLURL: fmt.Sprintf("https://forge.invalid/%s/pulls/%d", repo, number), Paths: paths,
|
||||
// Reviewed at its head, as the Approve is asked only for a reviewed head (novox/hq ADR 0496, issue 1803).
|
||||
Body: "Reviewed-by: tester@shanks at " + sha}
|
||||
}
|
||||
|
||||
// verdict is the controller's `checked` for a head.
|
||||
@@ -719,3 +722,5 @@ func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
|
||||
f.calls = append(f.calls, "comment "+body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *world) logs() string { return w.logbuf.String() }
|
||||
|
||||
@@ -467,7 +467,7 @@ func (h *Holder) fillFromAnnouncement(d *Delivery, p PullEvent) {
|
||||
// PullEdited takes a pull request's edit (novox/hq issue 1079): its description is read again for the review record, the
|
||||
// `after:` lines and the issues it names, exactly as an announcement reads them. An Approve ask still unanswered whose words
|
||||
// the new record changes is withdrawn and asked again with it read, without a word on the pull request: nothing was
|
||||
// approved, so no Approve ended. An answered ask is left as it is. The record is never a gate (ADR 0421 §5). An edit with no
|
||||
// approved, so no Approve ended. An answered ask is left as it is. A record naming the head lifts the hold at once (ADR 0496 §4). An edit with no
|
||||
// description, or of a pull request with no open delivery at that head, changes nothing.
|
||||
func (h *Holder) PullEdited(p PullEvent) {
|
||||
if p.HeadSHA == "" || p.Number == 0 {
|
||||
@@ -995,6 +995,12 @@ func (h *Holder) Checked(c CheckedEvent) {
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
if d.Ready != nil && !d.Ready.OwnAskedAt.IsZero() {
|
||||
// The own check asked for this head answered (issue 1792): one own ask is in flight at a time, so a status still
|
||||
// pending or missing after its answer is asked again.
|
||||
d.Ready.OwnAskedAt = time.Time{}
|
||||
h.keep(d)
|
||||
}
|
||||
if d.State == Rejected && c.Trunk != "" && d.Check != nil && !sameCommit(d.Check.Trunk, c.Trunk) {
|
||||
// Judged again against another trunk commit — the merge tool's ask of a verdict out of date, or a check asked
|
||||
// elsewhere (novox/hq ADR 0323 §5, §7): taken as the recheck it is.
|
||||
@@ -1390,10 +1396,31 @@ func (h *Holder) plan() []func() {
|
||||
// Following a walk that was taken over: the walk that took it over, read as soon as it is heard of, asked for
|
||||
// by its id when it is not.
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() || d.Walk == nil || d.Walk.TakenOverBy == "" {
|
||||
if d.Walk == nil || d.Walk.TakenOverBy == "" {
|
||||
continue
|
||||
}
|
||||
if d.State.Final() {
|
||||
// Ended superseded by its walk before the walk that took it over was heard to answer (issue 1786): the
|
||||
// state stays final, as a covered superseded member's does (issue 442), and the end of that walk is
|
||||
// kept as DeliveredBy, which the group and the views read. Never logged, never changed otherwise.
|
||||
if d.supersededByItsWalk() && d.DeliveredBy == "" {
|
||||
if s, known := h.walks[d.Walk.TakenOverBy]; known {
|
||||
if _, ok := walkAnswers(s.walk, d); ok && s.walk.State == walkDone {
|
||||
d.DeliveredBy = fmt.Sprintf("the walk %s, which took its walk %s over, ended done", s.walk.ID, d.Walk.ID)
|
||||
h.tellCovered(d, now)
|
||||
}
|
||||
} else if a := h.askWalk(d.Walk.TakenOverBy, "which took a delivery's walk over", now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s, known := h.walks[d.Walk.TakenOverBy]; known {
|
||||
if s.walk.IsBatch() {
|
||||
// A batch is no walk yet (ADR 0276): the walk it is cut into answers the merge. Waited for, never
|
||||
// read as a walk that does not answer it (issue 1786).
|
||||
continue
|
||||
}
|
||||
if c, ok := walkAnswers(s.walk, d); ok {
|
||||
h.linkWalk(d, s.walk, c)
|
||||
} else {
|
||||
@@ -2175,7 +2202,7 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
// moves — and its group counts it as reached (novox/hq issue 442).
|
||||
if g.Closed {
|
||||
for _, d := range members {
|
||||
if d.State != Superseded || d.CoveredBy != "" {
|
||||
if d.State != Superseded || d.CoveredBy != "" || d.DeliveredBy != "" {
|
||||
continue
|
||||
}
|
||||
var ask []string
|
||||
@@ -2190,15 +2217,22 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
}
|
||||
}
|
||||
// Its state, said when it changes.
|
||||
state, why := GroupState(g, members)
|
||||
state, why, held := h.groupState(g, members)
|
||||
status := groupStatusState(state)
|
||||
if held {
|
||||
status = "pending" // no Approve exists while a member waits for its review (novox/hq ADR 0496 §2)
|
||||
}
|
||||
if state == "delivering" && !(g.Check != nil && (g.Check.Verdict == "pass" || g.Check.Verdict == "warning") &&
|
||||
sameMembers(g.Check.Commits, commitsOf(members)) && g.Check.OutOfDate == "") {
|
||||
// Success there lets the merge tool merge its members: only for a composed check that passed for these heads.
|
||||
status = "pending"
|
||||
}
|
||||
if state != g.Said || status != g.SaidStatus {
|
||||
g.Said, g.SaidStatus = state, status
|
||||
reviewSaid := ""
|
||||
if held {
|
||||
reviewSaid = why
|
||||
}
|
||||
if state != g.Said || status != g.SaidStatus || reviewSaid != g.SaidReview {
|
||||
g.Said, g.SaidStatus, g.SaidReview = state, status, reviewSaid
|
||||
for _, d := range members {
|
||||
g.Owed = append(g.Owed, Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery-group",
|
||||
State: status, Line: clip(fmt.Sprintf("group %s %s: %s", g.ID, state, why), 140),
|
||||
@@ -2692,6 +2726,29 @@ func GroupState(g *Group, members []*Delivery) (string, string) {
|
||||
return "checking", fmt.Sprintf("%d of %d member(s) ready", count[Ready], n)
|
||||
}
|
||||
|
||||
// groupState is GroupState, but a group whose members all passed and compose waits for a review while any member the
|
||||
// Approve would cover has none at its head: its why names the members (novox/hq ADR 0496 §2, issue 1803), and held
|
||||
// says its mesh/delivery-group is pending, not a success. Under the lock.
|
||||
func (h *Holder) groupState(g *Group, members []*Delivery) (state, why string, held bool) {
|
||||
state, why = GroupState(g, members)
|
||||
if state != "ready" || g.Closed {
|
||||
return state, why, false
|
||||
}
|
||||
var waiting []string
|
||||
for _, m := range members {
|
||||
if m.MergedAs != "" || m.State.Final() || h.covering(m) != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := reviewWords(m); !ok {
|
||||
waiting = append(waiting, "at "+shortOf(m.Commit, 12)+" ("+pullSaid(m)+")")
|
||||
}
|
||||
}
|
||||
if len(waiting) == 0 {
|
||||
return state, why, false
|
||||
}
|
||||
return state, waitsForReview + strings.Join(waiting, " and ")[len("at "):], true
|
||||
}
|
||||
|
||||
// supersededWhy begins why a group whose members all ended is stopped. It is kept short: the forge's status line,
|
||||
// "group <id> stopped: <why>", is clipped at 140 characters, and the member's id must fit in it whole.
|
||||
const supersededWhy = "superseded, not carried by a later delivery: "
|
||||
|
||||
@@ -19,8 +19,12 @@ const (
|
||||
|
||||
func TestAPullRequestsHeadIsProposedCheckedAndReady(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
p := pr("novox/app", 7, head, "feat/x", "src/a.go")
|
||||
p.Body = "" // an empty description is never read again, so the review set below stays
|
||||
w.h.PullUpdated(p)
|
||||
id := IDOf("novox/app", head)
|
||||
// This head is not a full commit id, so its description line cannot name it: its review is set as read.
|
||||
w.h.deliveries[id].ReviewedBy, w.h.deliveries[id].ReviewedAt = "tester@shanks", head
|
||||
if w.state(id) != Proposed {
|
||||
t.Fatalf("a head is %s", w.state(id))
|
||||
}
|
||||
@@ -359,6 +363,9 @@ func TestAGroupIsCheckedTogetherAndLetGoOnceEveryMemberIsMerged(t *testing.T) {
|
||||
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
// These heads are not full commit ids, so their description lines cannot name them: the reviews are set as read.
|
||||
w.h.deliveries[ctl].ReviewedBy, w.h.deliveries[ctl].ReviewedAt = "tester@shanks", ctlHead
|
||||
w.h.deliveries[cat].ReviewedBy, w.h.deliveries[cat].ReviewedAt = "tester@shanks", catHead
|
||||
if w.delivery(ctl).Group != "feat/x" || w.delivery(cat).Group != "feat/x" {
|
||||
t.Fatalf("one branch name in two repositories made no group: %q %q", w.delivery(ctl).Group, w.delivery(cat).Group)
|
||||
}
|
||||
|
||||
@@ -10,18 +10,19 @@ import (
|
||||
// this head". The forge's edit event is followed; an Approve ask not yet answered is withdrawn and asked again with the
|
||||
// record read (the operator agreed, 2026-10-18). The review record is never a gate (ADR 0421 §5).
|
||||
|
||||
// askedUnreviewed is a green pull request whose Approve is asked while its description holds no Reviewed-by line.
|
||||
func askedUnreviewed(t *testing.T) *world {
|
||||
// askedReviewed is a green pull request whose Approve is asked, its review line naming the head by one reviewer.
|
||||
func askedReviewed(t *testing.T) *world {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Title = "Fix the apostrophe (issue 948)"
|
||||
p.Body = "Reviewed-by: first@shanks at " + readyHead
|
||||
w.h.PullUpdated(p)
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if open := w.op.open(); len(open) != 1 || !strings.Contains(w.op.ask(open[0]).Explanation, "not reviewed at this head") {
|
||||
if open := w.op.open(); len(open) != 1 || !strings.Contains(w.op.ask(open[0]).Explanation, "reviewed by first on shanks") {
|
||||
t.Fatalf("setup: the ask is %v", w.op.open())
|
||||
}
|
||||
return w
|
||||
@@ -33,10 +34,10 @@ func edited(body string) PullEvent {
|
||||
return p
|
||||
}
|
||||
|
||||
func TestAReviewedByLineWrittenAfterTheAskWithdrawsTheUnansweredAskAndAsksAgain(t *testing.T) {
|
||||
w := askedUnreviewed(t)
|
||||
func TestAReviewedByLineChangedAfterTheAskWithdrawsTheUnansweredAskAndAsksAgain(t *testing.T) {
|
||||
w := askedReviewed(t)
|
||||
first := w.op.open()[0]
|
||||
w.h.PullEdited(edited("Reviewed-by: reviewer@shanks at " + readyHead))
|
||||
w.h.PullEdited(edited("Reviewed-by: second@shanks at " + readyHead))
|
||||
w.settleAll()
|
||||
open := w.op.open()
|
||||
// The heads are the same, so the key is: the ask is taken away and written again with the record in its words, which the
|
||||
@@ -47,8 +48,7 @@ func TestAReviewedByLineWrittenAfterTheAskWithdrawsTheUnansweredAskAndAsksAgain(
|
||||
if len(w.op.cancelled) != 0 || w.op.byKey[first].Asking != 2 {
|
||||
t.Fatalf("the first ask was taken away (%v) or not written again as asking 2 (%d)", w.op.cancelled, w.op.byKey[first].Asking)
|
||||
}
|
||||
if e := w.op.ask(open[0]).Explanation; !strings.Contains(e, "reviewed by reviewer on shanks at "+shortOf(readyHead, 12)) ||
|
||||
strings.Contains(e, "not reviewed") {
|
||||
if e := w.op.ask(open[0]).Explanation; !strings.Contains(e, "reviewed by second on shanks at "+shortOf(readyHead, 12)) {
|
||||
t.Fatalf("the new ask does not say the record: %s", e)
|
||||
}
|
||||
if len(w.forge.comments) != 0 {
|
||||
@@ -63,35 +63,29 @@ func TestAReviewedByLineWrittenAfterTheAskWithdrawsTheUnansweredAskAndAsksAgain(
|
||||
}
|
||||
|
||||
func TestAnEditThatChangesNothingTheAskSaysAsksNothingAndNoEditChangesAnAnsweredAsk(t *testing.T) {
|
||||
w := askedUnreviewed(t)
|
||||
// The same words: no description with a line, then a description without one.
|
||||
w.h.PullEdited(edited("A new sentence in the description."))
|
||||
w := askedReviewed(t)
|
||||
// The same words: a description with the same line and a new sentence.
|
||||
w.h.PullEdited(edited("A new sentence.\nReviewed-by: first@shanks at " + readyHead))
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || len(w.op.cancelled) != 0 {
|
||||
t.Fatalf("an edit that changes no word of the ask asked again: %d asks, cancelled %v", w.op.count(), w.op.cancelled)
|
||||
}
|
||||
// An edit that brings a line for another head changes the words: the ask is made again, still saying not reviewed.
|
||||
w.h.PullEdited(edited("Reviewed-by: reviewer@shanks at " + strings.Repeat("a", 40)))
|
||||
w.settleAll()
|
||||
if w.op.count() != 2 || len(w.op.open()) != 1 {
|
||||
t.Fatalf("a line for another head: %d asks, open %v", w.op.count(), w.op.open())
|
||||
}
|
||||
// Answered: a later edit changes nothing.
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
asked := w.op.count()
|
||||
w.h.PullEdited(edited("Reviewed-by: reviewer@shanks at " + readyHead))
|
||||
w.h.PullEdited(edited("Reviewed-by: second@shanks at " + readyHead))
|
||||
w.settleAll()
|
||||
if w.op.count() != asked || len(w.op.open()) != 0 {
|
||||
t.Fatalf("an edit after the Approve asked again: %d asks, open %v", w.op.count(), w.op.open())
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", readyHead)); d.ReviewedBy != "reviewer@shanks" {
|
||||
if d := w.delivery(IDOf("novox/app", readyHead)); d.ReviewedBy != "second@shanks" {
|
||||
t.Fatalf("the record is still read: %q", d.ReviewedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEditOfAnUnknownOrMergedOrEmptyPullRequestChangesNothing(t *testing.T) {
|
||||
w := askedUnreviewed(t)
|
||||
w := askedReviewed(t)
|
||||
other := pr("novox/other", 3, readyHead, "feat/y")
|
||||
other.Body = "Reviewed-by: a@b at " + readyHead
|
||||
w.h.PullEdited(other) // no delivery: nothing made
|
||||
|
||||
@@ -173,3 +173,43 @@ func TestAGroupMergedWholeMovesSourceMainOnceAndAnnouncesWithTheTeller(t *testin
|
||||
t.Fatalf("source.main moved again: %d", len(w.source.moves))
|
||||
}
|
||||
}
|
||||
|
||||
// 1106: the same refusal again and again with the trunk not moved is not asked on without end. Three refusals in a row with
|
||||
// the same cause end the asking by a verdict, a status or a composed check, and say it urgent; a trunk's move, or the
|
||||
// operator's Approve, starts it again.
|
||||
func TestARefusalThatRepeatsWithNoTrunkMoveIsNotAskedAgainWithoutEnd(t *testing.T) {
|
||||
w := answeredFirst(t)
|
||||
d := w.h.deliveries[IDOf(gCat, gHk)]
|
||||
refuse := func(took string) {
|
||||
w.h.ForgeRefused(sdk.Forge{Kind: sdk.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge",
|
||||
Words: "novox/mesh-catalog#2 is not merged: its verdict names no trunk commit it was judged against, and main is now f70a99dbf485. " +
|
||||
"The check of head d2d2d2d2d2d2 merged into trunk f70a99dbf485 was asked; the last check took " + took + ". Ask again once both statuses pass"})
|
||||
}
|
||||
tried := w.now
|
||||
for i, took := range []string{"4m1s", "4m9s"} {
|
||||
refuse(took)
|
||||
d.Ready.LastTry = tried
|
||||
w.h.mergeAgainNow(d)
|
||||
if !d.Ready.LastTry.IsZero() {
|
||||
t.Fatalf("refusal %d: the merge was not asked again at once on a verdict, as after a first refusal", i+1)
|
||||
}
|
||||
}
|
||||
refuse("4m5s") // the third in a row, with only the check's duration changed
|
||||
if d.Ready.Repeats != 3 {
|
||||
t.Fatalf("%d refusals counted for the same cause, wanted 3", d.Ready.Repeats)
|
||||
}
|
||||
d.Ready.LastTry = tried
|
||||
w.h.mergeAgainNow(d) // the composed check or a verdict answers: nothing is asked
|
||||
if d.Ready.LastTry.IsZero() {
|
||||
t.Fatal("the merge was asked again after the same refusal three times with the trunk not moved")
|
||||
}
|
||||
if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "names no trunk commit") {
|
||||
t.Fatalf("the repeated refusal is not said urgent: %+v", lines)
|
||||
}
|
||||
// The trunk moves: the merge is asked again.
|
||||
d.TrunkNow = "a1a1a1a1a1a1a1a1"
|
||||
w.h.mergeAgainNow(d)
|
||||
if !d.Ready.LastTry.IsZero() {
|
||||
t.Fatal("a trunk's move did not let the merge be asked again")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 1786: two merges whose walks the controller folded, before they were cut, into a batch that was
|
||||
// still queued (not yet cut into a walk) ended superseded: the batch is no walk that answers a merge, so the follow
|
||||
// loop took the walk's taken_over_by off ("the walk that took its own over does not answer it") and the table's
|
||||
// supersession then held ("names no walk that took it over"). Delivered by the batch's walk, they showed
|
||||
// superseded for good. A batch that took a walk over is waited for; and one already ended so follows its taker.
|
||||
|
||||
const (
|
||||
head1786 = "1786178617861786178617861786178617861786"
|
||||
merge1786 = "a1786a1786a1786a1786a1786a1786a1786a17860"
|
||||
)
|
||||
|
||||
// folded1786 is a delivery whose walk was folded into a batch that is still queued.
|
||||
func folded1786(t *testing.T) (*world, string) {
|
||||
t.Helper()
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head1786)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head1786, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head1786, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head1786, merge1786))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-own", "novox/app", merge1786, true)))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Delivering {
|
||||
t.Fatalf("setup: %s, want delivering", s)
|
||||
}
|
||||
closes := w.now.Add(time.Minute)
|
||||
batch := Walk{ID: "plan-batch", Repository: "novox/other", Branch: "main", Commit: "ffff", Created: w.now, State: walkQueued,
|
||||
Delivery: &WalkDelivery{Awaits: "mesh-delivery", Batch: &WalkBatch{ClosesAt: &closes},
|
||||
Merges: []WalkMerge{{Repository: "novox/app", Commit: merge1786}}}}
|
||||
w.h.WalkMoved(w.ctl.put(batch))
|
||||
folded := w.ctl.walk("plan-own")
|
||||
folded.State = walkSuperseded
|
||||
folded.Note = "folded into plan-batch before it was cut"
|
||||
folded.Delivery.TakenOverBy = "plan-batch"
|
||||
w.h.WalkMoved(w.ctl.put(folded))
|
||||
w.settleAll()
|
||||
return w, id
|
||||
}
|
||||
|
||||
func cut1786(w *world, state string) {
|
||||
b := w.ctl.walk("plan-batch")
|
||||
b.State = state
|
||||
b.Delivery.Batch = nil
|
||||
b.Tiers, b.Modules = [][]string{{"app"}}, map[string]*WalkModule{"app": {}}
|
||||
w.h.WalkMoved(w.ctl.put(b))
|
||||
w.settleAll()
|
||||
}
|
||||
|
||||
func TestAWalkFoldedIntoAQueuedBatchIsNotSupersededAndFollowsTheBatchWhenItIsCut(t *testing.T) {
|
||||
w, id := folded1786(t)
|
||||
if d := w.delivery(id); d.State.Final() || d.Walk.TakenOverBy != "plan-batch" {
|
||||
t.Fatalf("folded into a queued batch it is %s, taken over by %q; wanted it still following plan-batch", d.State, d.Walk.TakenOverBy)
|
||||
}
|
||||
cut1786(w, walkBuilding)
|
||||
cut1786(w, walkDone)
|
||||
if s := w.state(id); s != Delivered {
|
||||
t.Fatalf("the batch's walk is done and it is %s; wanted delivered", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADeliverySupersededWhileItsTakerWasABatchIsHealedWhenTheTakerEndsDone(t *testing.T) {
|
||||
w, id := folded1786(t)
|
||||
d := w.h.deliveries[id]
|
||||
// The state kept by the fault: superseded, its walk record naming the walk that took it over.
|
||||
d.State = Superseded
|
||||
d.Transitions = append(d.Transitions, Transition{At: w.now, From: Delivering, To: Superseded, Event: EvSuperseded})
|
||||
if err := w.h.Store.PutDelivery(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cut1786(w, walkBuilding)
|
||||
cut1786(w, walkDone)
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
d = w.delivery(id)
|
||||
// A final state stays final (ADR 0239 §5): the taker's end is kept as DeliveredBy, as issue 442 and 747 do.
|
||||
if d.State != Superseded || d.DeliveredBy == "" || !memberReached(d) || !d.coveredStop() {
|
||||
t.Fatalf("superseded by the fault and its taker done, it is %s, delivered by %q, reached %v; wanted superseded, delivered by the taker, reached",
|
||||
d.State, d.DeliveredBy, memberReached(d))
|
||||
}
|
||||
if st, _ := statusOf(d); st != "success" {
|
||||
t.Fatalf("its status is %s; wanted success", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFinalDeliveryWhoseTakerDoesNotAnswerItIsNeitherHealedNorLoggedEveryPass(t *testing.T) {
|
||||
w, id := folded1786(t)
|
||||
d := w.h.deliveries[id]
|
||||
d.State = Superseded
|
||||
d.Transitions = append(d.Transitions, Transition{At: w.now, From: Delivering, To: Superseded, Event: EvSuperseded})
|
||||
other := aWalk("plan-batch", "novox/other", "ffff", false)
|
||||
other.State = walkDone
|
||||
w.h.WalkMoved(w.ctl.put(other))
|
||||
w.settleAll()
|
||||
before := w.logs()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); d.DeliveredBy != "" || d.State != Superseded {
|
||||
t.Fatalf("healed by a walk that does not answer it: %s %q", d.State, d.DeliveredBy)
|
||||
}
|
||||
if n := strings.Count(w.logs(), "does not answer its merge") - strings.Count(before, "does not answer its merge"); n != 0 {
|
||||
t.Fatalf("logged %d times on a pass over a final record", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASupersededDeliveryWithNoWalkThatTookItOverStaysSuperseded(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head1786)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head1786, "feat/x", "src/a.go"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, "b"+head1786[1:], "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Superseded {
|
||||
t.Fatalf("a head superseded by a newer one is %s", s)
|
||||
}
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Superseded {
|
||||
t.Fatalf("it moved: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
const gHk2 = "f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4"
|
||||
|
||||
// Issue 1792: a sibling's new head put a member's own mesh/repo-check back to pending ("out of date: judged beside ..."),
|
||||
// and nothing asked it again. The composed check passed, the group stayed held ("unasked") on the member's pending
|
||||
// status, and the operator's Approve was never asked. The group's hold on a member's own pending repository check asks
|
||||
// that check, once for the head, with no composed check (ADR 0382 §2: each member's own check stays required).
|
||||
func TestAGroupHeldBeforeItsAskOnAMembersOwnPendingRepoCheckAsksThatCheckOnce(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
setChecks(w, gCat, gHk, "success", "success")
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed %v; wanted the member's own check asked once", own, composed)
|
||||
}
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked with a required status pending: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
func composedPassAt(w *world, hc, hk string) {
|
||||
g := w.h.groups["feat/g"]
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: hc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: hk, Trunk: gTrunk0[:12]}}})
|
||||
}
|
||||
|
||||
// heldOnOwnPending is the group of issue 1792 after its first own ask: the member's own check asked, once.
|
||||
func heldOnOwnPending(t *testing.T) *world {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
setChecks(w, gCat, gHk, "success", "success")
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
asked := len(w.ctl.checks)
|
||||
composedPassAt(w, gHc, gHk)
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if own, _ := ownChecks(w, asked); len(own) != 1 {
|
||||
t.Fatalf("the first own ask: %v", own)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// A second sibling push puts the member's repo-check out of date again after its own check answered: asked again.
|
||||
func TestAMembersOwnCheckIsAskedAgainWhenASecondSiblingHeadPutsItOutOfDate(t *testing.T) {
|
||||
w := heldOnOwnPending(t)
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0)) // its own check answered
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.settleAll()
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk2, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCat, 2, gHk2, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
setChecks(w, gCat, gHk2, "success", "success")
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
asked := len(w.ctl.checks)
|
||||
composedPassAt(w, gHc, gHk2)
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own %v composed %v; wanted the member's own check asked again, once", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// An own ask before the Approve, answered, and then a merge refused for the member's own pending status at the same head:
|
||||
// asked again (the one ask is per answer, not per head's life).
|
||||
func TestAMergeRefusedForOwnPendingAfterAnAnsweredOwnAskAsksItAgain(t *testing.T) {
|
||||
w := heldOnOwnPending(t)
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if len(w.op.open()) != 1 {
|
||||
t.Fatalf("the group is not asked once its statuses are green: %d", w.op.count())
|
||||
}
|
||||
w.forge.refuseRepo = map[string]error{gCtl: ownPending}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own %v composed %v; wanted the member's own check asked again after the refusal", own, composed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 1803, ADR 0496: the Approve is asked only for a head with a review recorded at that head. A missing
|
||||
// line, `Reviewed-by: pending` or a line naming another commit holds the ask; the delivery stays ready and says it waits
|
||||
// for a review at the head. An edit naming the head asks at once. A group asks when every member is reviewed. An ask
|
||||
// already raised is not touched.
|
||||
|
||||
const stale1803 = "9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f"
|
||||
|
||||
// greenWithBody is a green pull request whose description is body, the operator not answering by himself.
|
||||
func greenWithBody(t *testing.T, body string) *world {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Title = "Fix the apostrophe (issue 948)"
|
||||
p.Body = body
|
||||
w.h.PullUpdated(p)
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *world) wantHeld(t *testing.T, label string) {
|
||||
t.Helper()
|
||||
want := "waits for a review at " + shortOf(readyHead, 12)
|
||||
d := w.delivery(IDOf("novox/app", readyHead))
|
||||
if w.op.count() != 0 || len(w.op.open()) != 0 {
|
||||
t.Fatalf("%s: the operator was asked (%d asks)", label, w.op.count())
|
||||
}
|
||||
if d.State != Ready || d.Ready == nil || !strings.Contains(d.Ready.Waits, want) {
|
||||
t.Fatalf("%s: state %s, waits %+v; wanted ready and %q", label, d.State, d.Ready, want)
|
||||
}
|
||||
found := false
|
||||
for k, v := range w.forge.statuses {
|
||||
if strings.HasSuffix(strings.SplitN(k, " ", 2)[1], "mesh/delivery") && strings.Contains(v, want) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s: no mesh/delivery status says %q: %v", label, want, w.forge.statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestA1803PendingReviewLineHoldsTheAsk(t *testing.T) {
|
||||
w := greenWithBody(t, "Reviewed-by: pending")
|
||||
w.wantHeld(t, "1803/AC1 pending")
|
||||
for range 3 {
|
||||
w.later(2 * 60 * 1e9)
|
||||
w.settleAll()
|
||||
}
|
||||
w.wantHeld(t, "1803/AC1 pending after ticks")
|
||||
}
|
||||
|
||||
func TestA1803MissingReviewLineHoldsTheAsk(t *testing.T) {
|
||||
w := greenWithBody(t, "No review line at all.")
|
||||
w.wantHeld(t, "1803/AC1 missing")
|
||||
}
|
||||
|
||||
func TestA1803ReviewLineOfAnotherCommitHoldsTheAsk(t *testing.T) {
|
||||
w := greenWithBody(t, "Reviewed-by: reviewer@shanks at "+stale1803)
|
||||
w.wantHeld(t, "1803/AC2 stale")
|
||||
}
|
||||
|
||||
func TestA1803ReviewLineNamingTheHeadIsAsked(t *testing.T) {
|
||||
w := greenWithBody(t, "Reviewed-by: reviewer@shanks at "+readyHead)
|
||||
if len(w.op.open()) != 1 {
|
||||
t.Fatalf("1803/AC3: a review at the head is not asked: %v", w.op.open())
|
||||
}
|
||||
if e := w.op.ask(w.op.open()[0]).Explanation; !strings.Contains(e, "reviewed by reviewer on shanks") {
|
||||
t.Fatalf("the ask does not name the review: %s", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestA1803AnEditNamingTheHeadRaisesTheAskAtOnce(t *testing.T) {
|
||||
w := greenWithBody(t, "Reviewed-by: pending")
|
||||
w.wantHeld(t, "1803/AC4 before the edit")
|
||||
e := pr("novox/app", 7, readyHead, "feat/x")
|
||||
e.Body = "Reviewed-by: reviewer@shanks at " + readyHead
|
||||
w.h.PullEdited(e)
|
||||
w.settleAll()
|
||||
if len(w.op.open()) != 1 {
|
||||
t.Fatalf("1803/AC4: the edit naming the head raised no ask: %d asks", w.op.count())
|
||||
}
|
||||
// An unanswered ask whose review is taken back is withdrawn: the hold applies again (ADR 0496 §1).
|
||||
e.Body = "Reviewed-by: pending"
|
||||
w.h.PullEdited(e)
|
||||
w.settleAll()
|
||||
if len(w.op.open()) != 0 {
|
||||
t.Fatalf("1803/AC4: an unanswered ask stayed after its review was taken back: open %v", w.op.open())
|
||||
}
|
||||
}
|
||||
|
||||
func TestA1803AnApproveAlreadyGivenIsNotAffected(t *testing.T) {
|
||||
w := greenWithBody(t, "Reviewed-by: reviewer@shanks at "+readyHead)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("1803/AC6: the approved head was not merged: %v", w.forge.merges)
|
||||
}
|
||||
// A later edit to pending changes nothing of the Approve given.
|
||||
e := pr("novox/app", 7, readyHead, "feat/x")
|
||||
e.Body = "Reviewed-by: pending"
|
||||
w.h.PullEdited(e)
|
||||
w.settleAll()
|
||||
if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready == nil || d.Ready.Approved == nil {
|
||||
t.Fatalf("1803/AC6: the Approve given was lost: %+v", d.Ready)
|
||||
}
|
||||
}
|
||||
|
||||
const gTools, gHt = "novox/mesh-tools", "e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3"
|
||||
|
||||
// groupOf is a green group of the given members (repository, number, head, review body), composed green, the operator not
|
||||
// answering by himself.
|
||||
func groupOf(t *testing.T, members [][4]string) *world {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
for i, m := range members {
|
||||
p := pr(m[0], i+1, m[2], "feat/g", "src/a.go")
|
||||
p.Body = m[3]
|
||||
w.h.PullUpdated(p)
|
||||
}
|
||||
for i, m := range members {
|
||||
w.h.Checked(judged(m[0], i+1, m[2], "pass", gTrunk0))
|
||||
}
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
if g == nil || g.Check == nil {
|
||||
t.Fatalf("no composed check was asked: %+v", g)
|
||||
}
|
||||
c := CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"}
|
||||
for _, m := range members {
|
||||
owner, repo, _ := strings.Cut(m[0], "/")
|
||||
c.Members = append(c.Members, struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{Owner: owner, Repo: repo, Commit: m[2], Trunk: gTrunk0[:12]})
|
||||
}
|
||||
w.h.Checked(c)
|
||||
w.settleAll()
|
||||
return w
|
||||
}
|
||||
|
||||
func reviewedAt(head string) string { return "Reviewed-by: reviewer@shanks at " + head }
|
||||
|
||||
// wantGroupSays checks the group's state, its mesh/delivery-group status and the member's mesh/delivery status.
|
||||
func (w *world) wantGroupSays(t *testing.T, label, member, head string, want, notWant string) {
|
||||
t.Helper()
|
||||
g := w.h.groups["feat/g"]
|
||||
state, why, held := w.h.groupState(g, w.h.membersOf(g))
|
||||
if state != "ready" || !held || !strings.Contains(why, want) || (notWant != "" && strings.Contains(why, notWant)) {
|
||||
t.Fatalf("%s: group state %s, why %q, held %v; wanted ready waiting with %q and without %q", label, state, why, held, want, notWant)
|
||||
}
|
||||
for _, c := range []string{"mesh/delivery-group", "mesh/delivery"} {
|
||||
said := w.forge.statuses[member+"@"+head+" "+c]
|
||||
if !strings.HasPrefix(said, "pending") || !strings.Contains(said, want) || (notWant != "" && strings.Contains(said, notWant)) {
|
||||
t.Fatalf("%s: %s on %s says %q; wanted pending with %q and without %q", label, c, member, said, want, notWant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestA1803AGroupIsAskedOnlyWhenEveryMemberIsReviewedAtItsHead(t *testing.T) {
|
||||
w := groupOf(t, [][4]string{{gCtl, "", gHc, reviewedAt(gHc)}, {gCat, "", gHk, "Reviewed-by: pending"}})
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("1803/AC5: a group with an unreviewed member was asked: %d asks", w.op.count())
|
||||
}
|
||||
w.wantGroupSays(t, "1803/AC5", gCtl, gHc, "mesh-catalog#2", "mesh-controller#1")
|
||||
e := pr(gCat, 2, gHk, "feat/g")
|
||||
e.Body = reviewedAt(gHk)
|
||||
w.h.PullEdited(e)
|
||||
w.settleAll()
|
||||
if len(w.op.open()) != 1 {
|
||||
t.Fatalf("1803/AC5: every member reviewed, yet %d asks", w.op.count())
|
||||
}
|
||||
if said := w.forge.statuses[gCtl+"@"+gHc+" mesh/delivery-group"]; !strings.HasPrefix(said, "success") {
|
||||
t.Fatalf("1803/AC5: the group's status once asked is %q", said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestA1803TheWaitingWordsFollowTheMembersStillWaiting(t *testing.T) {
|
||||
w := groupOf(t, [][4]string{{gCtl, "", gHc, reviewedAt(gHc)}, {gCat, "", gHk, "Reviewed-by: pending"}, {gTools, "", gHt, "Reviewed-by: pending"}})
|
||||
w.wantGroupSays(t, "1803/AC7 two waiting", gCtl, gHc, "mesh-catalog#2", "")
|
||||
w.wantGroupSays(t, "1803/AC7 two waiting", gCtl, gHc, "mesh-tools#3", "")
|
||||
e := pr(gCat, 2, gHk, "feat/g")
|
||||
e.Body = reviewedAt(gHk)
|
||||
w.h.PullEdited(e)
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("1803/AC7: a member still waits, yet %d asks", w.op.count())
|
||||
}
|
||||
// Only the other member is named now, on the group and on every member's own status.
|
||||
w.wantGroupSays(t, "1803/AC7 one waiting", gCtl, gHc, "mesh-tools#3", "mesh-catalog#2")
|
||||
w.wantGroupSays(t, "1803/AC7 one waiting", gCat, gHk, "mesh-tools#3", "mesh-catalog#2")
|
||||
}
|
||||
|
||||
func TestA1803APushAfterAnApprovingReviewHoldsTheNewHead(t *testing.T) {
|
||||
w := greenWithBody(t, reviewedAt(readyHead))
|
||||
if len(w.op.open()) != 1 {
|
||||
t.Fatalf("setup: the reviewed head is not asked")
|
||||
}
|
||||
// A new head on the same pull request whose description still names the old commit.
|
||||
const pushed2 = "7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a"
|
||||
p := pr("novox/app", 7, pushed2, "feat/x", "src/a.go")
|
||||
p.Body = reviewedAt(readyHead)
|
||||
w.h.PullUpdated(p)
|
||||
w.h.Checked(judgedAt("novox/app", 7, pushed2, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
d := w.delivery(IDOf("novox/app", pushed2))
|
||||
if d.Ready == nil || !strings.Contains(d.Ready.Waits, waitsForReview+shortOf(pushed2, 12)) {
|
||||
t.Fatalf("1803/AC8: the new head waits %+v; wanted it to wait for a review at %s", d.Ready, shortOf(pushed2, 12))
|
||||
}
|
||||
if len(w.op.open()) != 0 {
|
||||
t.Fatalf("1803/AC8: the new head was asked on the old review: %v", w.op.open())
|
||||
}
|
||||
}
|
||||
@@ -287,9 +287,20 @@ func (h *Holder) ForgeRefused(f sdk.Forge) {
|
||||
if r.RefusedSince == nil {
|
||||
r.RefusedSince = &now
|
||||
}
|
||||
r.noteRefusal(d, answer)
|
||||
if !outOfDateRefusal(answer) {
|
||||
r.Urgent = true // never a warning again for this head
|
||||
}
|
||||
if r.Repeats >= maxRepeatedRefusals {
|
||||
// Loud, not endless (novox/hq issue 1106): the same refusal again with the trunk not moved is no trunk's move, so
|
||||
// the merge is not asked again by a verdict or a status; the operator is told, urgent, and decides.
|
||||
r.Urgent = true
|
||||
if r.Repeats == maxRepeatedRefusals {
|
||||
h.Logf("[mesh-delivery] %s: the merge tool refused it %d times in a row with the same words and the trunk not moved, so "+
|
||||
"the merge is asked no more until the trunk moves, a new head is announced or the operator approves again: %s",
|
||||
d.ID, r.Repeats, answer)
|
||||
}
|
||||
}
|
||||
if answer != r.Answer {
|
||||
h.Logf("[mesh-delivery] %s is approved, and the merge tool did not merge it (asked again at the next verdict, Approve or composed check): %s", d.ID,
|
||||
answer)
|
||||
|
||||
@@ -132,7 +132,7 @@ type Delivery struct {
|
||||
Ready *ReadyRun `json:"ready,omitempty"`
|
||||
// ReviewedBy and ReviewedAt are the review record its pull request's description holds: the line
|
||||
// `Reviewed-by: <who> at <full sha>` (novox/hq ADR 0421 §5, issue 1022), as written. Read at every head; never a
|
||||
// gate: the ask says first when the line is missing or names another head.
|
||||
// hold: no Approve is asked until it names the head (novox/hq ADR 0496, issue 1803).
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
// FreshWanted is whether a passing verdict that arrived out of date is to have its check asked against the
|
||||
@@ -351,7 +351,7 @@ func (d *Delivery) coveredStop() bool {
|
||||
switch d.State {
|
||||
case Stopped:
|
||||
return d.Stranded != "" && d.CoveredBy != "" || d.DeliveredBy != ""
|
||||
case Failed:
|
||||
case Failed, Superseded:
|
||||
return d.DeliveredBy != ""
|
||||
}
|
||||
return false
|
||||
@@ -397,6 +397,9 @@ func (d *Delivery) strandedEndedSaid() string {
|
||||
|
||||
// coveredStopSaid is how a covered stop or failure reads: on the forge's status, in its view and note, and in `show`.
|
||||
func (d *Delivery) coveredStopSaid() string {
|
||||
if d.State == Superseded {
|
||||
return "delivered after its walk was superseded: " + d.DeliveredBy
|
||||
}
|
||||
ended := "its stop"
|
||||
if d.State == Failed {
|
||||
ended = "its failure"
|
||||
@@ -496,7 +499,7 @@ func ReviewIn(body string) (who, sha string) {
|
||||
}
|
||||
|
||||
// reviewWords is what the delivery's review record says of its head, for the ask: reviewed by whom, or, first, that it
|
||||
// is not reviewed at this head (never a gate, ADR 0421 §5). ok is whether it is reviewed at this head.
|
||||
// is not reviewed at this head (the ask is held then, ADR 0496). ok is whether it is reviewed at this head.
|
||||
func reviewWords(d *Delivery) (words string, ok bool) {
|
||||
switch {
|
||||
case d.ReviewedBy == "":
|
||||
@@ -1016,7 +1019,10 @@ type Group struct {
|
||||
Hold *GroupHold `json:"hold,omitempty"`
|
||||
// SaidStatus is the mesh/delivery-group status last written for it: a change of the status a state takes is
|
||||
// written too, though the state did not change (novox/hq issue 902).
|
||||
SaidStatus string `json:"said_status,omitempty"`
|
||||
SaidStatus string `json:"said_status,omitempty"`
|
||||
// SaidReview is the wait for a review last said of the group while it was held for one (ADR 0496 §2): a change of
|
||||
// the members still waiting is said too, though the state did not change.
|
||||
SaidReview string `json:"said_review,omitempty"`
|
||||
Owed []Effect `json:"owed,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1071,3 +1077,13 @@ func mustJSON(v any) json.RawMessage {
|
||||
raw, _ := json.Marshal(v)
|
||||
return raw
|
||||
}
|
||||
|
||||
// supersededByItsWalk is whether a delivery ended superseded because its walk was: its last move was a supersession
|
||||
// from published or delivering, not a newer head of its pull request's (issue 1786).
|
||||
func (d *Delivery) supersededByItsWalk() bool {
|
||||
if d.State != Superseded || len(d.Transitions) == 0 {
|
||||
return false
|
||||
}
|
||||
t := d.Transitions[len(d.Transitions)-1]
|
||||
return t.Event == EvSuperseded && (t.From == Published || t.From == Delivering)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,11 @@ type ReadyRun struct {
|
||||
// Urgent is set once a refusal of this head was one that is urgent at once: its severity never steps down to a
|
||||
// warning after (issue 1275, AC6).
|
||||
Urgent bool `json:"urgent,omitempty"`
|
||||
// Repeats is how many times in a row the merge tool refused with the same words while the trunk stayed where RepeatTrunk
|
||||
// says: past maxRepeatedRefusals the merge is asked no more by a verdict, a status or a composed check, and the refusal
|
||||
// is urgent (novox/hq issue 1106). A trunk's move, a new head or the operator's Approve starts the count again.
|
||||
Repeats int `json:"repeats,omitempty"`
|
||||
RepeatTrunk string `json:"repeat_trunk,omitempty"`
|
||||
// RefusedSince is when the merge tool began refusing it without a break.
|
||||
RefusedSince *time.Time `json:"refused_since,omitempty"`
|
||||
// Merged is when the merge tool merged it.
|
||||
@@ -214,7 +219,14 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
r.Failed = ""
|
||||
waits := h.readyWaits(d)
|
||||
if waits != r.Waits {
|
||||
wasHeld := d.holdsForReview()
|
||||
r.Waits = waits
|
||||
if (wasHeld || d.holdsForReview()) && d.shownOnItsPullRequest() {
|
||||
// The status and the view say what it waits for, again whenever the words change while held, and no more once asked.
|
||||
state, desc := statusOf(d)
|
||||
d.owe(Effect{Kind: EffectView, Since: now})
|
||||
d.owe(Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery", State: state, Line: desc, Since: now})
|
||||
}
|
||||
if waits != "" {
|
||||
h.Logf("[mesh-delivery] %s waits: %s", d.ID, waits)
|
||||
}
|
||||
@@ -322,10 +334,43 @@ func (h *Holder) askFresh(d *Delivery, now time.Time) func() {
|
||||
// verdict, an Approve, a group's composed check), so the wait after a refusal does not hold it. Under the lock.
|
||||
func (h *Holder) mergeAgainNow(d *Delivery) {
|
||||
if d != nil && d.Ready != nil && d.Ready.Merged == nil {
|
||||
if d.Ready.repeatedRefusal(d) {
|
||||
return // the same refusal again and again with the trunk where it was: asking more changes nothing (issue 1106)
|
||||
}
|
||||
d.Ready.LastTry = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
// maxRepeatedRefusals is how many refusals in a row, with the same words and the trunk not moved, end the asking again: a
|
||||
// refusal that repeats with no trunk move is not a trunk's move, and the check asked after each only brings the same words.
|
||||
const maxRepeatedRefusals = 3
|
||||
|
||||
// repeatedRefusal is whether the merge was refused maxRepeatedRefusals times with these words and the trunk where it is
|
||||
// now: it is then asked no more until something changes (a trunk's move, a new head, the operator's Approve).
|
||||
func (r *ReadyRun) repeatedRefusal(d *Delivery) bool {
|
||||
return r.Repeats >= maxRepeatedRefusals && r.RepeatTrunk == d.TrunkNow
|
||||
}
|
||||
|
||||
// refusalKey is a refusal's first sentence, up to the trunk it names ("... and main is now <t>"): what follows says how long the
|
||||
// last check took and the like, which differs each time and is no change of cause.
|
||||
func refusalKey(answer string) string {
|
||||
if i := strings.Index(answer, " is now "); i >= 0 {
|
||||
if j := strings.Index(answer[i:], ". "); j >= 0 {
|
||||
return answer[:i+j]
|
||||
}
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
// noteRefusal counts a refusal: the same words with the trunk where it was count on, anything else starts the count at one.
|
||||
func (r *ReadyRun) noteRefusal(d *Delivery, answer string) {
|
||||
if refusalKey(answer) == refusalKey(r.Answer) && r.RepeatTrunk == d.TrunkNow && r.Repeats > 0 {
|
||||
r.Repeats++
|
||||
return
|
||||
}
|
||||
r.Repeats, r.RepeatTrunk = 1, d.TrunkNow
|
||||
}
|
||||
|
||||
// readyWaits is what a delivery's merge waits on; empty when the Approve may be asked and the merge asked. A ready
|
||||
// delivery is asked. So is a proposed one whose verdict went out of date, when an Approve was asked for it already (it
|
||||
// stays through a trunk move, ADR 0421 §3) and its check against the trunk's head is not on its way; one without an
|
||||
@@ -343,12 +388,48 @@ func (h *Holder) readyWaits(d *Delivery) string {
|
||||
default:
|
||||
return "its check has not passed yet (" + string(d.State) + ")"
|
||||
}
|
||||
// ADR 0496 §1, §2: the Approve is asked only for heads with a review recorded at them. An ask already raised or
|
||||
// answered is not touched (covering).
|
||||
if h.covering(d) == nil {
|
||||
if w := h.reviewWaits(d); w != "" {
|
||||
return w
|
||||
}
|
||||
}
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return h.groupTurnToMerge(d)
|
||||
}
|
||||
|
||||
// waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it.
|
||||
const waitsForReview = "waits for a review at "
|
||||
|
||||
// reviewWaits is why the Approve is not asked yet for want of a review (novox/hq ADR 0496, issue 1803): a delivery
|
||||
// whose description holds no review line, `pending` or a line naming another commit, and for a group any member so.
|
||||
// Empty when every delivery the ask covers is reviewed at its head. Under the lock.
|
||||
func (h *Holder) reviewWaits(d *Delivery) string {
|
||||
members := h.askMembers(d)
|
||||
var waiting []string
|
||||
for _, m := range members {
|
||||
if _, ok := reviewWords(m); ok {
|
||||
continue
|
||||
}
|
||||
if len(members) == 1 {
|
||||
return waitsForReview + shortOf(m.Commit, 12)
|
||||
}
|
||||
waiting = append(waiting, "at "+shortOf(m.Commit, 12)+" ("+pullSaid(m)+")")
|
||||
}
|
||||
if len(waiting) == 0 {
|
||||
return ""
|
||||
}
|
||||
return waitsForReview + strings.Join(waiting, " and ")[len("at "):]
|
||||
}
|
||||
|
||||
// holdsForReview is whether a delivery's wait is the hold for a review.
|
||||
func (d *Delivery) holdsForReview() bool {
|
||||
return d.Ready != nil && strings.HasPrefix(d.Ready.Waits, waitsForReview)
|
||||
}
|
||||
|
||||
// groupTurnToMerge is why a group member may not merge yet; empty when it may: the group's composed check passed for
|
||||
// its members' heads as they are and is not out of date. No member waits for another's merge or delivery (novox/hq
|
||||
// ADR 0418 §4): once the composed check passed and the Approve covers the heads, every member is merged in one pass.
|
||||
|
||||
@@ -134,14 +134,14 @@ func (h *Holder) Show(id string) (any, error) {
|
||||
out["reads"] = d.strandedEndedSaid() // stopped, and nothing can carry what it waited for (issue 1581)
|
||||
}
|
||||
if g := h.groups[d.Group]; g != nil {
|
||||
state, why := GroupState(g, h.membersOf(g))
|
||||
state, why, _ := h.groupState(g, h.membersOf(g))
|
||||
out["group"] = map[string]any{"group": g, "state": state, "why": why}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if g := h.groups[id]; g != nil {
|
||||
members := h.membersOf(g)
|
||||
state, why := GroupState(g, members)
|
||||
state, why, _ := h.groupState(g, members)
|
||||
return map[string]any{"group": g, "state": state, "why": why, "members": members}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no delivery or group %q: `deliveries` lists them", id)
|
||||
@@ -184,7 +184,7 @@ func (h *Holder) Groups(all bool) []GroupLine {
|
||||
if len(members) == 0 {
|
||||
continue
|
||||
}
|
||||
state, why := GroupState(g, members)
|
||||
state, why, _ := h.groupState(g, members)
|
||||
if !all && (state == "delivered" || state == "failed" || state == "stopped") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -119,6 +119,9 @@ type Client struct {
|
||||
Templates *templateStore
|
||||
// Now dates the reports; nil is time.Now.
|
||||
Now func() time.Time
|
||||
// SocketUp wakes the client when the node-engine's write socket is made again (watchSocket); nil never does.
|
||||
// A module whose write failed for want of the socket is filled again then (issue 1165), and nothing else is.
|
||||
SocketUp <-chan struct{}
|
||||
|
||||
// watchBound watches a provider's bucket of entries for this machine's keys, once per run (consumers.go); set by
|
||||
// Run.
|
||||
@@ -147,6 +150,8 @@ type state struct {
|
||||
// of the consumers left out of one of its files.
|
||||
providing map[string]bool
|
||||
leftOut map[string]string
|
||||
// unplaced is a module whose last fill failed for want of the node-engine's write socket (issue 1165).
|
||||
unplaced map[string]bool
|
||||
// bound is each provider's bucket of entries this client watches, by key: this machine's consumers' entries alone;
|
||||
// boundReady is a bucket whose current entries are all read, and boundUsers the modules filled from each.
|
||||
bound map[string]map[string][]byte
|
||||
@@ -161,6 +166,8 @@ type update struct {
|
||||
changed []byte
|
||||
// asked is the tool runner asking for a module's value (asks.go). Set, entry is nil.
|
||||
asked *nats.Msg
|
||||
// socketUp is the node-engine's write socket made again (issue 1165). Set, entry is nil.
|
||||
socketUp bool
|
||||
}
|
||||
|
||||
// Run serves until ctx ends or a watch stops, which it returns: run again, it starts from the current values.
|
||||
@@ -248,6 +255,23 @@ func (c *Client) Run(ctx context.Context) error {
|
||||
return fmt.Errorf("listening for %s: %w", SecretChangedSubject, err)
|
||||
}
|
||||
defer func() { _ = said.Unsubscribe() }()
|
||||
// The node-engine's write socket made again: what failed for want of it is filled again, in the loop below.
|
||||
if c.SocketUp != nil {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.SocketUp:
|
||||
select {
|
||||
case updates <- update{socketUp: true}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
// This machine's tool runner asking for a module's config value (asks.go): answered from this run's state, in
|
||||
// its loop, so an answer is never read from half an update.
|
||||
asks, err := c.Conn.Subscribe(AskSubject(c.Node), func(m *nats.Msg) {
|
||||
@@ -266,7 +290,7 @@ func (c *Client) Run(ctx context.Context) error {
|
||||
reported: map[string][]byte{}, secrets: map[string]heldSecret{}, items: map[string]map[string]itemValue{},
|
||||
recorded: map[string][]byte{}, assigned: map[string]bool{}, binds: map[string]binding{},
|
||||
providing: map[string]bool{}, leftOut: map[string]string{}, bound: map[string]map[string][]byte{},
|
||||
boundReady: map[string]bool{}, boundUsers: map[string]map[string]bool{}}
|
||||
boundReady: map[string]bool{}, unplaced: map[string]bool{}, boundUsers: map[string]map[string]bool{}}
|
||||
// Every current value first: nothing is filled from half of them.
|
||||
current := map[string]bool{}
|
||||
var early []*nats.Msg
|
||||
@@ -282,6 +306,9 @@ func (c *Client) Run(ctx context.Context) error {
|
||||
early = append(early, u.asked)
|
||||
continue
|
||||
}
|
||||
if u.socketUp {
|
||||
continue // the first fill below tries every file
|
||||
}
|
||||
if u.changed != nil {
|
||||
// Nothing is held yet: every secret is asked for when it is first filled.
|
||||
continue
|
||||
@@ -326,6 +353,10 @@ func (c *Client) Run(ctx context.Context) error {
|
||||
switch {
|
||||
case u.asked != nil:
|
||||
c.respond(s, u.asked)
|
||||
case u.socketUp:
|
||||
for m := range s.unplaced {
|
||||
dirty[m] = true
|
||||
}
|
||||
case u.changed != nil:
|
||||
for _, m := range c.secretChanged(s, u.changed) {
|
||||
dirty[m] = true
|
||||
@@ -550,6 +581,7 @@ func (c *Client) leftModules(s *state) []string {
|
||||
// leave removes a module's needs and its report: it is no longer the provisioner's on this node.
|
||||
func (c *Client) leave(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, module string) {
|
||||
prefix := c.Node + "." + module + "."
|
||||
delete(s.unplaced, module)
|
||||
removed := 0
|
||||
for _, key := range sortedKeys(s.needs) {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
@@ -660,6 +692,7 @@ func (c *Client) reconcile(ctx context.Context, kvs map[string]jetstream.KeyValu
|
||||
c.forgetItems(ctx, kvs, s, module, keep)
|
||||
}
|
||||
moved := false
|
||||
delete(s.unplaced, module)
|
||||
for _, f := range targets {
|
||||
// File by file (issue 1553, ADR 0349 clause 4): a target with a value that waits keeps the file there is.
|
||||
if !allKnown(f.text, values) {
|
||||
@@ -672,6 +705,9 @@ func (c *Client) reconcile(ctx context.Context, kvs map[string]jetstream.KeyValu
|
||||
err = errFill(a)
|
||||
}
|
||||
if err != nil {
|
||||
if socketAbsent(err) {
|
||||
s.unplaced[module] = true
|
||||
}
|
||||
failed = append(failed, fmt.Sprintf("%s: %v", f.Path, err))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -200,6 +200,14 @@ type engine struct {
|
||||
}
|
||||
|
||||
func startEngine(t *testing.T, owners map[string]string) *engine {
|
||||
t.Helper()
|
||||
e := newEngine(t, owners)
|
||||
e.listen(t)
|
||||
return e
|
||||
}
|
||||
|
||||
// newEngine is an engine whose socket is not made yet: a node-engine not started, or replacing itself.
|
||||
func newEngine(t *testing.T, owners map[string]string) *engine {
|
||||
t.Helper()
|
||||
// A unix socket's path is short: under /tmp, not the test's own directory.
|
||||
dir, err := os.MkdirTemp("/tmp", "p5-")
|
||||
@@ -209,6 +217,12 @@ func startEngine(t *testing.T, owners map[string]string) *engine {
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
e := &engine{root: t.TempDir(), socket: filepath.Join(dir, "fill.sock"), owner: owners,
|
||||
moved: map[string]int{}, asked: map[string]int{}, wrote: map[string]string{}}
|
||||
return e
|
||||
}
|
||||
|
||||
// listen makes the socket and answers on it until stop, which removes it as a node-engine stopping does.
|
||||
func (e *engine) listen(t *testing.T) (stop func()) {
|
||||
t.Helper()
|
||||
l, err := net.Listen("unix", e.socket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -233,7 +247,7 @@ func startEngine(t *testing.T, owners map[string]string) *engine {
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return e
|
||||
return func() { l.Close(); os.Remove(e.socket) }
|
||||
}
|
||||
|
||||
func (e *engine) at(path string) string { return filepath.Join(e.root, path) }
|
||||
@@ -349,10 +363,14 @@ func aClient(t *testing.T, b *aBus, e *engine, state string) (*Client, func()) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secrets.Within = 2 * time.Second
|
||||
c := &Client{Node: node, JS: js, Conn: nc, Secrets: secrets,
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
up, err := watchSocket(ctx, e.socket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &Client{Node: node, JS: js, Conn: nc, Secrets: secrets, SocketUp: up,
|
||||
Fill: SocketFiller{Socket: e.socket, Busy: 10 * time.Millisecond, Tries: 3},
|
||||
Templates: &templateStore{dir: state, fetch: HTTPFetch}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.Run(ctx) }()
|
||||
stopped := false
|
||||
@@ -703,3 +721,23 @@ func TestNatsResticsFilesFillFromTheBackupPlacement(t *testing.T) {
|
||||
b.assign(t, "restic", plan("# a\n/srv/a\n", "# a\nstate\n"))
|
||||
eventually(t, "a module taken away refills backups.conf", func() bool { return e.read(t, backups) == head+"# a\n/srv/a\n" })
|
||||
}
|
||||
|
||||
// defaults writes the defaults a module's manifest declares as mesh-config resolves them (source default), for a
|
||||
// moved module whose templates read config values (issue 1780).
|
||||
func (b *aBus) defaults(t *testing.T, module string, manifest []byte) {
|
||||
t.Helper()
|
||||
var m struct {
|
||||
Config map[string]struct {
|
||||
Default json.RawMessage `json:"default"`
|
||||
} `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal(manifest, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, d := range m.Config {
|
||||
if len(d.Default) > 0 && string(d.Default) != "null" {
|
||||
b.put(t, ConfigBucket, node+"."+module+"."+key, fmt.Sprintf(`{"node":%q,"module":%q,"key":%q,"value":%s,`+
|
||||
`"source":"default"}`, node, module, key, d.Default))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ func TestNatsTheEnvironmentOfTheCataloguesModulesIsWhatTheOldFormComposed(t *tes
|
||||
if !manifest.Moved || len(manifest.Items) == 0 {
|
||||
t.Fatalf("%s is not a moved contributor of items", m)
|
||||
}
|
||||
b.defaults(t, m, read(m+"/module.json"))
|
||||
texts := map[string]string{}
|
||||
var planned []PlannedItem
|
||||
for _, it := range manifest.Items {
|
||||
|
||||
@@ -133,7 +133,11 @@ func serve(ctx context.Context, credential, socket, stateDir string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &Client{Node: c.Node, JS: js, Conn: nc, Secrets: secrets,
|
||||
up, err := watchSocket(ctx, socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &Client{Node: c.Node, SocketUp: up, JS: js, Conn: nc, Secrets: secrets,
|
||||
Fill: SocketFiller{Socket: socket, Busy: time.Second, Tries: 10},
|
||||
Templates: &templateStore{dir: filepath.Join(stateDir, "templates"), fetch: HTTPFetch}}
|
||||
wait := time.Second
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// Issue 1165: these tests make a real unix socket and watch its directory, so they run in the integration level.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue 1165: a file whose write failed because the node-engine's socket was gone is written when the socket is made
|
||||
// again, with no timer; a file that was placed is not asked again by it.
|
||||
func TestNatsAFillThatFailedForWantOfTheSocketIsTriedWhenTheSocketReturns(t *testing.T) {
|
||||
b := startBus(t)
|
||||
heard := b.listen(t)
|
||||
e := newEngine(t, map[string]string{dunstrc: "owned"})
|
||||
b.config(t, "dunst", "font-size", "13")
|
||||
b.assign(t, "dunst", dunst(t))
|
||||
aClient(t, b, e, t.TempDir())
|
||||
|
||||
eventually(t, "the failure is said", func() bool {
|
||||
r, _ := heard.last("dunst")
|
||||
return r.Status == StatusFailed && strings.Contains(r.Reason, "fill.sock")
|
||||
})
|
||||
if got := e.read(t, dunstrc); got != "" {
|
||||
t.Fatalf("written without a socket: %q", got)
|
||||
}
|
||||
e.listen(t)
|
||||
eventually(t, "dunstrc is written once the socket is back", func() bool {
|
||||
return strings.Contains(e.read(t, dunstrc), "Monospace 13")
|
||||
})
|
||||
eventually(t, "and the report says applied", func() bool {
|
||||
r, _ := heard.last("dunst")
|
||||
return r.Status == StatusApplied
|
||||
})
|
||||
}
|
||||
|
||||
func TestNatsAReturningSocketAsksNothingOfAModuleThatWasPlaced(t *testing.T) {
|
||||
b := startBus(t)
|
||||
e := newEngine(t, map[string]string{dunstrc: "owned"})
|
||||
stop := e.listen(t)
|
||||
b.config(t, "dunst", "font-size", "13")
|
||||
b.assign(t, "dunst", dunst(t))
|
||||
aClient(t, b, e, t.TempDir())
|
||||
eventually(t, "dunstrc is filled", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 13") })
|
||||
_, before := e.counts(dunstrc)
|
||||
|
||||
// The node-engine replaces itself: the socket goes and returns, and a later change proves the loop ran past it.
|
||||
stop()
|
||||
e.listen(t)
|
||||
b.config(t, "dunst", "font-size", "15")
|
||||
eventually(t, "the change is written", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 15") })
|
||||
_, after := e.counts(dunstrc)
|
||||
if after-before != 1 {
|
||||
t.Errorf("dunstrc was asked %d times for one change", after-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchSocketWakesWhenTheSocketIsMadeAndWhenItsDirectoryReturns(t *testing.T) {
|
||||
base, err := os.MkdirTemp("/tmp", "ws-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(base) })
|
||||
dir := filepath.Join(base, "engine")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
socket := filepath.Join(dir, "fill.sock")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
up, err := watchSocket(ctx, socket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heard := func(what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-up:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("no wake for %s", what)
|
||||
}
|
||||
}
|
||||
l, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heard("the socket made")
|
||||
l.Close()
|
||||
// The whole directory goes, as with a runtime directory removed on stop, and returns with the socket.
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l, err = net.Listen("unix", socket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
heard("the directory and socket back")
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// socketAbsent says whether a write failed because nothing answers at the node-engine's write socket: its file is
|
||||
// gone, or it is there with no one listening. That is the node-engine replacing itself (issue 1165); the write is
|
||||
// tried again when the socket is back (watchSocket).
|
||||
func socketAbsent(err error) bool {
|
||||
return errors.Is(err, syscall.ENOENT) || errors.Is(err, syscall.ECONNREFUSED)
|
||||
}
|
||||
|
||||
// watchSocket tells, on the channel it returns, each time the file at socket is made, moved in or changed in its
|
||||
// attributes — a node-engine that starts makes its write socket and sets its mode — until ctx ends. It is the
|
||||
// operating system's own event (inotify), not a timer: the client is on the node-engine's machine. Events that
|
||||
// come while one is unread are one. When the socket's directory is gone it watches the directory above for its
|
||||
// return. The channel only wakes: what is owed a write is the client's to read.
|
||||
func watchSocket(ctx context.Context, socket string) (<-chan struct{}, error) {
|
||||
fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("watching %s: %w", socket, err)
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), "inotify")
|
||||
out := make(chan struct{}, 1)
|
||||
dir := filepath.Dir(socket)
|
||||
// The directory's watch and, while the directory is absent, its parent's.
|
||||
if err := addWatches(fd, dir); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("watching %s: %w", socket, err)
|
||||
}
|
||||
go func() { <-ctx.Done(); _ = f.Close() }()
|
||||
go func() {
|
||||
buf := make([]byte, 16*(unix.SizeofInotifyEvent+unix.NAME_MAX+1))
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
wake, rewatch := false, false
|
||||
for off := 0; off+unix.SizeofInotifyEvent <= n; {
|
||||
ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
|
||||
name := ""
|
||||
if ev.Len > 0 {
|
||||
raw := buf[off+unix.SizeofInotifyEvent : off+unix.SizeofInotifyEvent+int(ev.Len)]
|
||||
for i, b := range raw {
|
||||
if b == 0 {
|
||||
raw = raw[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
name = string(raw)
|
||||
}
|
||||
off += unix.SizeofInotifyEvent + int(ev.Len)
|
||||
switch {
|
||||
case ev.Mask&(unix.IN_DELETE_SELF|unix.IN_MOVE_SELF|unix.IN_IGNORED) != 0:
|
||||
rewatch = true
|
||||
case name == filepath.Base(socket):
|
||||
wake = true
|
||||
case name == filepath.Base(dir):
|
||||
// The directory came back: watched again, and the socket may already be in it.
|
||||
rewatch, wake = true, true
|
||||
}
|
||||
}
|
||||
if rewatch {
|
||||
if err := addWatches(fd, dir); err != nil {
|
||||
logf("[provisioner-client] watching %s again: %v", socket, err)
|
||||
}
|
||||
if _, err := os.Lstat(socket); err == nil {
|
||||
wake = true
|
||||
}
|
||||
}
|
||||
if wake {
|
||||
select {
|
||||
case out <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addWatches watches dir for what is made in it, and its parent for dir's own return; a watch already there is kept.
|
||||
func addWatches(fd int, dir string) error {
|
||||
if _, err := unix.InotifyAddWatch(fd, filepath.Dir(dir), unix.IN_CREATE|unix.IN_MOVED_TO); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := unix.InotifyAddWatch(fd, dir, unix.IN_CREATE|unix.IN_MOVED_TO|unix.IN_ATTRIB|unix.IN_DELETE_SELF|
|
||||
unix.IN_MOVE_SELF)
|
||||
if errors.Is(err, syscall.ENOENT) {
|
||||
return nil // absent: its parent's watch tells when it is made
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -63,6 +63,7 @@ func TestNatsTheSessionFilesFilledFromTheirContributorsItemsAreWhatTheOldFormCom
|
||||
if !c.Moved || len(c.Shell) != 0 {
|
||||
t.Fatalf("%s is not moved or still holds shell entries", m)
|
||||
}
|
||||
b.defaults(t, m, []byte(read(m+"/module.json")))
|
||||
texts := map[string]string{}
|
||||
var planned []PlannedItem
|
||||
for _, it := range c.Items {
|
||||
|
||||
@@ -6,6 +6,7 @@ require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/sys v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -17,6 +18,5 @@ require (
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
+36
-5
@@ -256,11 +256,22 @@ export class MinioClient {
|
||||
if (argv.some((a) => a.includes(this.rootPassword))) {
|
||||
throw new Error("refused to run mc: its command line would carry the root password (novox/hq issue 282)");
|
||||
}
|
||||
const { stdout } = await execFileAsync(this.mcBin, argv, {
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, MC_HOST_mesh: mcHost(this.baseUrl, this.rootUser, this.rootPassword) },
|
||||
});
|
||||
return stdout.trim();
|
||||
const mcHostValue = mcHost(this.baseUrl, this.rootUser, this.rootPassword);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(this.mcBin, argv, {
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, MC_HOST_mesh: mcHostValue },
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (err) {
|
||||
// A fresh error with only the redacted message: execFile's own carries the whole command line, and
|
||||
// its cmd, stdout and stderr properties would carry it on (issue 1766).
|
||||
const known = [mcHostValue, this.rootPassword, encodeURIComponent(this.rootPassword), ...secretArgs(argv)];
|
||||
const e = err as { stderr?: unknown; message?: string };
|
||||
const reason = typeof e.stderr === "string" && e.stderr.trim() !== "" ? e.stderr.trim() : (e.message ?? String(err));
|
||||
const shown = argv.filter((a) => a !== this.mcConfigDir).join(" ");
|
||||
throw new Error(redactSecrets(`mc ${shown} failed: ${reason}`, known));
|
||||
}
|
||||
}
|
||||
|
||||
// --- SigV4 request plumbing ---------------------------------------------
|
||||
@@ -360,6 +371,26 @@ function uriEncode(str: string, encodeSlash: boolean): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The values that follow --secret-key and --access-key in an argument list. */
|
||||
function secretArgs(argv: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
argv.forEach((a, i) => { if ((a === "--secret-key" || a === "--access-key") && i + 1 < argv.length) out.push(argv[i + 1]); });
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one redaction every `mc` failure goes through (issue 1766): the token after --secret-key or
|
||||
* --access-key (as a separate word or after "="), any MC_HOST_ value, and each known secret, wherever they
|
||||
* appear in the text, become "[redacted]".
|
||||
*/
|
||||
export function redactSecrets(text: string, known: string[] = []): string {
|
||||
let out = text
|
||||
.replace(/(--(?:secret|access)-key(?:=|\s+))('[^']*'|"[^"]*"|\S+)/g, "$1[redacted]")
|
||||
.replace(/(MC_HOST_\w+=)\S+/g, "$1[redacted]");
|
||||
for (const k of known) if (k !== "") out = out.split(k).join("[redacted]");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MC_HOST_<alias> URL `mc` reads an alias from: the endpoint with the credentials in its userinfo,
|
||||
* each percent-encoded so a reserved character in either cannot end it early.
|
||||
|
||||
@@ -36,6 +36,9 @@ export function linkPolicy(bucket: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** A link key's secret: 40 hex characters, minio's limit for a secret key (issue 1766). */
|
||||
const LINK_SECRET = /^[0-9a-f]{40}$/;
|
||||
|
||||
export function linkSecretPath(dir: string, bucket: string): string {
|
||||
return join(dir, `${bucket}.secret`);
|
||||
}
|
||||
@@ -47,9 +50,12 @@ export async function ensureLinkKey(admin: LinkAdmin, dir: string, bucket: strin
|
||||
const path = linkSecretPath(dir, bucket);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
let secret = existsSync(path) ? readFileSync(path, "utf8").trim() : "";
|
||||
// A stored secret of the wrong shape (an earlier version made 64 characters; minio takes at most 40) is
|
||||
// replaced, and its key made again (issue 1766).
|
||||
if (!LINK_SECRET.test(secret)) secret = "";
|
||||
if (secret !== "" && (await admin.accessKeyExists(id))) return;
|
||||
if (secret === "") {
|
||||
secret = randomBytes(32).toString("hex");
|
||||
secret = randomBytes(20).toString("hex");
|
||||
writeFileSync(path, secret + "\n", { mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
@@ -115,3 +115,58 @@ test("the link key id is the one the object-store tool derives (cmd/object-store
|
||||
// 1516/AC1
|
||||
assert.equal(linkKeyId("brother"), "link0b4bd77cec705cf5");
|
||||
});
|
||||
|
||||
// Issue 1766: a link key's secret is minio's length, and no mc failure carries an argument's secret.
|
||||
|
||||
import { chmodSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { MinioClient } from "../client.ts";
|
||||
|
||||
test("a link key's secret is 40 characters, and a stored 64-character secret is replaced", async () => {
|
||||
// 1766/AC1
|
||||
const { minio, dir } = setup();
|
||||
await ensureLinkKey(minio, dir, "fresh");
|
||||
assert.equal(minio.keys.get(linkKeyId("fresh"))!.secret.length, 40);
|
||||
assert.match(minio.keys.get(linkKeyId("fresh"))!.secret, /^[0-9a-f]{40}$/);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const old = "ab".repeat(32);
|
||||
writeFileSync(linkSecretPath(dir, "old"), old + "\n");
|
||||
await ensureLinkKey(minio, dir, "old");
|
||||
const made = minio.keys.get(linkKeyId("old"))!.secret;
|
||||
assert.equal(made.length, 40);
|
||||
assert.notEqual(made, old);
|
||||
assert.equal(readFileSync(linkSecretPath(dir, "old"), "utf8").trim(), made);
|
||||
});
|
||||
|
||||
function failingMc(): { client: MinioClient; root: string } {
|
||||
const d = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "minio-mc-"));
|
||||
const bin = join(d, "mc");
|
||||
// Says its arguments and its alias on stderr, then fails, the way a refused command does.
|
||||
writeFileSync(bin, '#!/bin/sh\necho "args: $*" >&2\necho "env: MC_HOST_mesh=$MC_HOST_mesh" >&2\nexit 1\n');
|
||||
chmodSync(bin, 0o755);
|
||||
const root = "RootPass-s3cret/ok";
|
||||
const client = new MinioClient({ endpoint: "http://127.0.0.1:9000", rootUser: "root", rootPassword: root, region: "us-east-1", mcBin: bin, mcConfigDir: d });
|
||||
return { client, root };
|
||||
}
|
||||
|
||||
test("an mc failure's message holds no --secret-key value and no MC_HOST value", async () => {
|
||||
// 1766/AC2
|
||||
const { client, root } = failingMc();
|
||||
const secret = "SuperSecretValue0123456789";
|
||||
const err: any = await client.createAccessKey("b", "ACCESSKEYID1234567", secret).then(() => null, (e) => e);
|
||||
assert.ok(err, "the failure is raised");
|
||||
for (const text of [String(err.message), String(err.stack ?? ""), JSON.stringify(Object.getOwnPropertyNames(err).map((k) => err[k]))]) {
|
||||
assert.ok(!text.includes(secret), "no secret key");
|
||||
assert.ok(!text.includes("ACCESSKEYID1234567"), "no access key");
|
||||
assert.ok(!text.includes(root) && !text.includes(encodeURIComponent(root)), "no root password");
|
||||
assert.ok(!text.includes("root:"), "no MC_HOST value");
|
||||
}
|
||||
assert.match(err.message, /\[redacted\]/);
|
||||
});
|
||||
|
||||
test("a consumer key's create passes the minted secret unchanged", async () => {
|
||||
// 1766/AC3
|
||||
const s = setup();
|
||||
await s.h.create(provision);
|
||||
assert.equal(s.minio.keys.get("brother-key")!.secret, "consumer-secret");
|
||||
});
|
||||
|
||||
+12
-5
@@ -21,17 +21,24 @@ They are those both workstations ran, with three changes:
|
||||
- **scrollback:** `saveLines` 10000 instead of 2000.
|
||||
|
||||
The palette (background, foreground and the sixteen colours) is today's, unchanged. So are the
|
||||
clipboard keys: Ctrl+Shift+C copies to the clipboard and Ctrl+Shift+V pastes from it. The bundle's
|
||||
`Resources()` is the one source of these values. The manifest test holds the manifest to it, and
|
||||
`xterm_colours` compares against it.
|
||||
clipboard keys: Ctrl+Shift+C copies to the clipboard and Ctrl+Shift+V pastes from it.
|
||||
|
||||
Every one of these values is a **config value** (ADR 0348; issue 1656) with today's value as its default:
|
||||
`face`, `size` (the predefined `font-size`, 6 to 72), `save-lines`, `term-name`, `render-font`, `bold-mode`,
|
||||
`always-bold-mode`, `allow-bold-fonts`, `background`, `foreground`, `color0` to `color15` (each `#rrggbb`),
|
||||
and the two clipboard bindings `copy-binding` and `paste-binding`. `files/xresources-normal` holds only
|
||||
`${config:<key>}` placeholders, so a machine changes its font size through mesh-config. The manifest test holds
|
||||
the file to the keys, and the file filled with the defaults to the text every machine had before.
|
||||
The tools keep no copy of the values: `xterm_font` and `xterm_colours` read the `XTerm*` lines of the rendered
|
||||
resources file `xorg` writes (`~/.config/xorg/xresources`) as the module's value, and say so when it is not there.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `node-terminal-emulator.open` | d | a terminal in the session: the login shell, or a command run through it (`$SHELL -lc`), in a directory (the home by default), with an optional title, and `hold` to keep it open. It starts as a transient unit of the account's service manager (`systemd-run --user`), so it outlives the call and every runtime restart |
|
||||
| `xterm_font` | r/a | the face and size in force and the installed font they resolve to (`fc-match`); set face or size for terminals opened from now on. Only installed faces are accepted |
|
||||
| `xterm_colours` | r | background, foreground, cursor and the sixteen colours as `#rrggbb`, each beside the module's default |
|
||||
| `xterm_font` | r/a | the face and size in force beside the module's value (from the rendered resources), and the installed font they resolve to (`fc-match`); set face or size for terminals opened from now on. Only installed faces are accepted |
|
||||
| `xterm_colours` | r | background, foreground, cursor and the sixteen colours as `#rrggbb`, each beside the module's value from the rendered resources |
|
||||
|
||||
## What it leaves found
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
// todaysResources is the file every machine had before the module took config values (novox/hq issue 1656).
|
||||
var todaysResources = strings.Join([]string{
|
||||
"! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they",
|
||||
"! outrank any `*name` resource of yours and touch no other X program.",
|
||||
"XTerm*termName: xterm-256color",
|
||||
"XTerm*saveLines: 10000",
|
||||
"XTerm*renderFont: true",
|
||||
"XTerm*faceName: JetBrainsMono Nerd Font",
|
||||
"XTerm*faceSize: 12",
|
||||
"XTerm*boldMode: false",
|
||||
"XTerm*alwaysBoldMode: false",
|
||||
"XTerm*allowBoldFonts: true",
|
||||
"XTerm*background: #000000",
|
||||
"XTerm*foreground: #a8a8a8",
|
||||
"XTerm*color0: #000000",
|
||||
"XTerm*color8: #666666",
|
||||
"XTerm*color1: #9e1828",
|
||||
"XTerm*color9: #bc5766",
|
||||
"XTerm*color2: #008800",
|
||||
"XTerm*color10: #61a171",
|
||||
"XTerm*color3: #d2bb4b",
|
||||
"XTerm*color11: #e7db52",
|
||||
"XTerm*color4: #414171",
|
||||
"XTerm*color12: #5085af",
|
||||
"XTerm*color5: #963c59",
|
||||
"XTerm*color13: #a97a99",
|
||||
"XTerm*color6: #418179",
|
||||
"XTerm*color14: #6ba4a4",
|
||||
"XTerm*color7: #bebebe",
|
||||
"XTerm*color15: #ffffff",
|
||||
"XTerm*VT100.Translations: #override \\n\\",
|
||||
" Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\",
|
||||
" Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
var placeholder = regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`)
|
||||
|
||||
// asText is a default as the controller writes a value into a file: a string as it is, a number without a
|
||||
// needless fraction, a boolean as true or false.
|
||||
func asText(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(v)
|
||||
}
|
||||
t.Fatalf("a default of no type: %v", v)
|
||||
return ""
|
||||
}
|
||||
|
||||
func template(t *testing.T) string {
|
||||
t.Helper()
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
return m.Shell[0].Code
|
||||
}
|
||||
|
||||
// 1656/AC1: every value the resources set is a config value with today's value as its default.
|
||||
func TestEveryResourceValueIsAConfigValueWithTodaysDefault(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
want := map[string]any{
|
||||
"face": "JetBrainsMono Nerd Font", "size": 12.0, "save-lines": 10000.0, "term-name": "xterm-256color",
|
||||
"render-font": true, "bold-mode": false, "always-bold-mode": false, "allow-bold-fonts": true,
|
||||
"background": "#000000", "foreground": "#a8a8a8",
|
||||
"color0": "#000000", "color1": "#9e1828", "color2": "#008800", "color3": "#d2bb4b",
|
||||
"color4": "#414171", "color5": "#963c59", "color6": "#418179", "color7": "#bebebe",
|
||||
"color8": "#666666", "color9": "#bc5766", "color10": "#61a171", "color11": "#e7db52",
|
||||
"color12": "#5085af", "color13": "#a97a99", "color14": "#6ba4a4", "color15": "#ffffff",
|
||||
"copy-binding": "Ctrl Shift ~Alt <Key> C", "paste-binding": "Ctrl Shift ~Alt <Key> V",
|
||||
}
|
||||
if len(m.Config) != len(want) {
|
||||
t.Errorf("declares %d config values, want %d", len(m.Config), len(want))
|
||||
}
|
||||
for key, def := range want {
|
||||
c, ok := m.Config[key]
|
||||
if !ok {
|
||||
t.Errorf("%s: not declared", key)
|
||||
continue
|
||||
}
|
||||
if c.Default != def || c.Required || strings.TrimSpace(c.Description) == "" {
|
||||
t.Errorf("%s: %+v, want default %v and a description", key, c, def)
|
||||
}
|
||||
wantType := map[bool]string{true: "string"}[true]
|
||||
switch def.(type) {
|
||||
case float64:
|
||||
wantType = "number"
|
||||
case bool:
|
||||
wantType = "boolean"
|
||||
}
|
||||
if c.Type != wantType {
|
||||
t.Errorf("%s: type %q, want %q", key, c.Type, wantType)
|
||||
}
|
||||
}
|
||||
size := m.Config["size"]
|
||||
if size.Predefined != "font-size" || size.Minimum == nil || *size.Minimum != 6 || size.Maximum == nil || *size.Maximum != 72 {
|
||||
t.Errorf("size: %+v, want the predefined font-size from 6 to 72", size)
|
||||
}
|
||||
names := []string{"background", "foreground"}
|
||||
for i := 0; i < 16; i++ {
|
||||
names = append(names, "color"+strconv.Itoa(i))
|
||||
}
|
||||
for _, key := range names {
|
||||
c := m.Config[key]
|
||||
re, err := regexp.Compile("^(?:" + c.Pattern + ")$")
|
||||
if err != nil || c.Pattern == "" {
|
||||
t.Errorf("%s: pattern %q", key, c.Pattern)
|
||||
continue
|
||||
}
|
||||
if !re.MatchString("#0a1B2c") || re.MatchString("red") || re.MatchString("#12345") || re.MatchString("#1234567") {
|
||||
t.Errorf("%s: pattern %q is not #rrggbb", key, c.Pattern)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"copy-binding", "paste-binding"} {
|
||||
re := regexp.MustCompile("^(?:" + m.Config[key].Pattern + ")$")
|
||||
if !re.MatchString("Ctrl Shift ~Alt <Key> C") || re.MatchString("a: b") || re.MatchString("a\nb") || re.MatchString(`a\`) {
|
||||
t.Errorf("%s: pattern %q lets a value end the resource", key, m.Config[key].Pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC2: no literal value is left in the resources, and every declared key is used.
|
||||
func TestEveryResourceValueIsAPlaceholderOfADeclaredKeyAndEveryKeyIsUsed(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
text := template(t)
|
||||
used := map[string]bool{}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
switch {
|
||||
case l == "", strings.HasPrefix(l, "!"):
|
||||
continue
|
||||
case strings.HasPrefix(l, "XTerm*VT100.Translations: #override"):
|
||||
continue
|
||||
}
|
||||
_, value, ok := strings.Cut(l, ": ")
|
||||
if strings.HasPrefix(l, " ") {
|
||||
// a translation line: `<binding>: <action> \n\`
|
||||
value, _, _ = strings.Cut(strings.TrimSpace(l), ": ")
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("not a resource: %q", l)
|
||||
continue
|
||||
}
|
||||
f := placeholder.FindStringSubmatch(value)
|
||||
if f == nil || f[0] != value {
|
||||
t.Errorf("a literal value, not a ${config:<key>}: %q", l)
|
||||
continue
|
||||
}
|
||||
if _, declared := m.Config[f[1]]; !declared {
|
||||
t.Errorf("%q names a key the module does not declare", l)
|
||||
}
|
||||
used[f[1]] = true
|
||||
}
|
||||
for key := range m.Config {
|
||||
if !used[key] {
|
||||
t.Errorf("%s is declared and no resource uses it", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC3: filled with the defaults, the resources are the file every machine had.
|
||||
func TestFilledWithTheDefaultsTheResourcesAreTodaysFileByteForByte(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
got := placeholder.ReplaceAllStringFunc(template(t), func(ref string) string {
|
||||
c, ok := m.Config[placeholder.FindStringSubmatch(ref)[1]]
|
||||
if !ok {
|
||||
t.Fatalf("%s: no such key", ref)
|
||||
}
|
||||
return asText(t, c.Default)
|
||||
})
|
||||
if got != todaysResources {
|
||||
t.Fatalf("filled with the defaults:\n%s\nwant:\n%s", got, todaysResources)
|
||||
}
|
||||
}
|
||||
|
||||
// renderedWith is the resources file xorg writes with this module's piece filled from the defaults, and
|
||||
// the given values changed.
|
||||
func renderedWith(t *testing.T, changed map[string]string) string {
|
||||
t.Helper()
|
||||
m := readManifest(t)
|
||||
text := placeholder.ReplaceAllStringFunc(template(t), func(ref string) string {
|
||||
key := placeholder.FindStringSubmatch(ref)[1]
|
||||
if v, ok := changed[key]; ok {
|
||||
return v
|
||||
}
|
||||
return asText(t, m.Config[key].Default)
|
||||
})
|
||||
return "! The mesh's X resources (module xorg)\nXft.dpi: 96\n# xterm\n" + text + "Xft.rgba: rgb\n"
|
||||
}
|
||||
|
||||
func deskWithResources(t *testing.T, rendered string, f *fake) xterm {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "xresources")
|
||||
if rendered != "" {
|
||||
if err := os.WriteFile(path, []byte(rendered), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return xterm{d: f.desk(true), resources: path}
|
||||
}
|
||||
|
||||
// 1656/AC4: the tools' defaults are what the module's resources were rendered with, not constants.
|
||||
func TestFontReportsTheDefaultTheModuleRenderedNotAConstant(t *testing.T) {
|
||||
f := &fake{query: "XTerm*faceName:\tInter\nXTerm*faceSize:\t13\n"}
|
||||
x := deskWithResources(t, renderedWith(t, map[string]string{"size": "10", "face": "Fira Code"}), f)
|
||||
got, err := x.font(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if j := asJSON(got); !strings.Contains(j, `"default":{"face":"Fira Code","size":10}`) {
|
||||
t.Fatalf("%s", j)
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC4
|
||||
func TestColoursReportTheDefaultTheModuleRenderedNotAConstant(t *testing.T) {
|
||||
f := &fake{query: "XTerm*color1:\t#aa0000\nXTerm*foreground:\t#112233\nXTerm*color2:\trgb:00/88/01\n"}
|
||||
x := deskWithResources(t, renderedWith(t, map[string]string{"color1": "#aa0000", "foreground": "#112233"}), f)
|
||||
got, err := x.colours(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := asJSON(got)
|
||||
for _, want := range []string{`{"name":"color1","value":"#aa0000","default":"#aa0000"}`,
|
||||
`{"name":"foreground","value":"#112233","default":"#112233"}`,
|
||||
`{"name":"color2","value":"#008801","default":"#008800","differs":true}`} {
|
||||
if !strings.Contains(j, want) {
|
||||
t.Errorf("lacks %s in %s", want, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC4: without the rendered file there is no default to report, said loudly.
|
||||
func TestWithoutTheRenderedResourcesTheToolsSayWhy(t *testing.T) {
|
||||
x := deskWithResources(t, "", &fake{})
|
||||
if _, err := x.font(context.Background(), desktop.Args{}); err == nil || !strings.Contains(err.Error(), "xresources") {
|
||||
t.Fatalf("font: %v", err)
|
||||
}
|
||||
if _, err := x.colours(context.Background(), desktop.Args{}); err == nil || !strings.Contains(err.Error(), "xresources") {
|
||||
t.Fatalf("colours: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1656/AC4: tools.go holds no copy of the defaults.
|
||||
func TestTheToolsHoldNoCopyOfTheDefaults(t *testing.T) {
|
||||
src, err := os.ReadFile("tools.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, lit := range []string{"JetBrainsMono", "#a8a8a8", "#9e1828", "xterm-256color", "Face =", "Size =", "func Resources"} {
|
||||
if strings.Contains(string(src), lit) {
|
||||
t.Errorf("tools.go holds %q", lit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(xterm{d: desktop.Machine("i3", "sway")})); err != nil {
|
||||
if err := stdio.Serve("", tools(xterm{d: desktop.Machine("i3", "sway"), resources: filepath.Join(desktop.Home(), ResourcesFile)})); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -53,7 +54,7 @@ func tools(x xterm) []stdio.Tool {
|
||||
"resources set the default again at the next login.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"face": desktop.Str("a font family, as fc-list names it"),
|
||||
"size": desktop.Num("points, 6 to 48"),
|
||||
"size": desktop.Num("points, 6 to 72"),
|
||||
}),
|
||||
Run: call(x.font),
|
||||
},
|
||||
|
||||
@@ -11,47 +11,50 @@ import (
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
type xterm struct{ d desktop.Desk }
|
||||
|
||||
// The module's defaults: the face research 026/04 chose and both workstations' palette.
|
||||
const (
|
||||
Face = "JetBrainsMono Nerd Font"
|
||||
Size = 12
|
||||
)
|
||||
|
||||
// Palette is the colours both workstations ran, by resource name, in the order they are written.
|
||||
var Palette = [][2]string{
|
||||
{"background", "#000000"}, {"foreground", "#a8a8a8"},
|
||||
{"color0", "#000000"}, {"color8", "#666666"},
|
||||
{"color1", "#9e1828"}, {"color9", "#bc5766"},
|
||||
{"color2", "#008800"}, {"color10", "#61a171"},
|
||||
{"color3", "#d2bb4b"}, {"color11", "#e7db52"},
|
||||
{"color4", "#414171"}, {"color12", "#5085af"},
|
||||
{"color5", "#963c59"}, {"color13", "#a97a99"},
|
||||
{"color6", "#418179"}, {"color14", "#6ba4a4"},
|
||||
{"color7", "#bebebe"}, {"color15", "#ffffff"},
|
||||
type xterm struct {
|
||||
d desktop.Desk
|
||||
resources string
|
||||
}
|
||||
|
||||
// Resources is the module's contribution to the X resources (the xresources slot `normal`), as the
|
||||
// manifest carries it; the manifest test holds the two to one text.
|
||||
func Resources() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they\n")
|
||||
b.WriteString("! outrank any `*name` resource of yours and touch no other X program.\n")
|
||||
for _, kv := range [][2]string{
|
||||
{"termName", "xterm-256color"}, {"saveLines", "10000"}, {"renderFont", "true"},
|
||||
{"faceName", Face}, {"faceSize", strconv.Itoa(Size)},
|
||||
{"boldMode", "false"}, {"alwaysBoldMode", "false"}, {"allowBoldFonts", "true"},
|
||||
} {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
// PaletteNames are the colour resources xterm_colours reports, in the order the resources write them.
|
||||
// Their values are the module's config values, read from the rendered resources (Rendered), never held here.
|
||||
var PaletteNames = []string{
|
||||
"background", "foreground",
|
||||
"color0", "color8", "color1", "color9", "color2", "color10", "color3", "color11",
|
||||
"color4", "color12", "color5", "color13", "color6", "color14", "color7", "color15",
|
||||
}
|
||||
|
||||
// ResourcesFile is where xorg writes the X resources, under the account's home: this module's resources are
|
||||
// its contribution to the `normal` slot, filled with the module's config values.
|
||||
const ResourcesFile = ".config/xorg/xresources"
|
||||
|
||||
// Rendered is the XTerm* resources of a rendered resources file, by name: `XTerm*faceSize: 12` is faceSize 12.
|
||||
// The translations, which span lines, and every resource of another program are left out.
|
||||
func Rendered(text string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
k, v, ok := strings.Cut(l, ":")
|
||||
name, scoped := strings.CutPrefix(strings.TrimSpace(k), "XTerm*")
|
||||
if ok && scoped && !strings.Contains(name, ".") {
|
||||
out[name] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
for _, kv := range Palette {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
return out
|
||||
}
|
||||
|
||||
// defaults are the resources the module's config values rendered, as xorg placed them: what the module sets, so
|
||||
// what a terminal has unless the session's own resources say otherwise.
|
||||
func (x xterm) defaults() (map[string]string, error) {
|
||||
text, err := os.ReadFile(x.resources)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the module's resources are not where xorg places them (%s): is xorg assigned to this machine, "+
|
||||
"and has its xresources file been written? %w", x.resources, err)
|
||||
}
|
||||
b.WriteString("XTerm*VT100.Translations: #override \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)\n")
|
||||
return b.String()
|
||||
got := Rendered(string(text))
|
||||
if len(got) == 0 {
|
||||
return nil, fmt.Errorf("%s holds no XTerm* resource: the xterm module's resources are not in it", x.resources)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// openScript runs in the new terminal: into the directory, then the login shell, or a command
|
||||
@@ -124,6 +127,10 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
def, err := x.defaults()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
face := a.Opt("face", "")
|
||||
size, sized, err := a.Number("size")
|
||||
@@ -142,8 +149,8 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
res = append(res, "XTerm*faceName: "+face)
|
||||
}
|
||||
if sized {
|
||||
if size < 6 || size > 48 {
|
||||
return nil, fmt.Errorf("size is from 6 to 48 points")
|
||||
if size < 6 || size > 72 {
|
||||
return nil, fmt.Errorf("size is from 6 to 72 points")
|
||||
}
|
||||
res = append(res, "XTerm*faceSize: "+strconv.FormatFloat(size, 'f', -1, 64))
|
||||
}
|
||||
@@ -156,9 +163,13 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
return nil, q.Err()
|
||||
}
|
||||
inForce := Effective(q.Stdout, "faceName")
|
||||
var points any = def["faceSize"]
|
||||
if n, err := strconv.ParseFloat(def["faceSize"], 64); err == nil {
|
||||
points = n
|
||||
}
|
||||
answer := map[string]any{
|
||||
"session": s.Display, "face": inForce, "size": Effective(q.Stdout, "faceSize"),
|
||||
"default": map[string]any{"face": Face, "size": Size},
|
||||
"default": map[string]any{"face": def["faceName"], "size": points},
|
||||
}
|
||||
if inForce != "" {
|
||||
m := x.d.Run(ctx, env, nil, "fc-match", "-f", "%{family[0]}|%{style[0]}|%{file}", inForce)
|
||||
@@ -175,7 +186,7 @@ func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
|
||||
var rgbSpec = regexp.MustCompile(`^rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})$`)
|
||||
|
||||
// Hex writes an X colour (`rgb:9e/18/28`, `#9e1828`) as #rrggbb; anything else (a colour name) as it is.
|
||||
// Hex writes an X colour (`rgb:9e/18/28`, `#rrggbb`) as #rrggbb; anything else (a colour name) as it is.
|
||||
func Hex(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if m := rgbSpec.FindStringSubmatch(v); m != nil {
|
||||
@@ -191,6 +202,10 @@ func Hex(v string) string {
|
||||
}
|
||||
|
||||
func (x xterm) colours(ctx context.Context, a desktop.Args) (any, error) {
|
||||
def, err := x.defaults()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, s, err := x.d.InSession(ctx, "xrdb", "-query")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -205,9 +220,9 @@ func (x xterm) colours(ctx context.Context, a desktop.Args) (any, error) {
|
||||
Differs bool `json:"differs,omitempty"`
|
||||
}
|
||||
var out []colour
|
||||
for _, kv := range Palette {
|
||||
v := Hex(Effective(res.Stdout, kv[0]))
|
||||
out = append(out, colour{kv[0], v, kv[1], v != kv[1]})
|
||||
for _, name := range PaletteNames {
|
||||
v, d := Hex(Effective(res.Stdout, name)), Hex(def[name])
|
||||
out = append(out, colour{name, v, d, v != d})
|
||||
}
|
||||
cursor := Hex(Effective(res.Stdout, "cursorColor"))
|
||||
return map[string]any{"session": s.Display, "colours": out, "cursor": cursor}, nil
|
||||
|
||||
@@ -19,7 +19,8 @@ type manifest struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Tools []string `json:"tools"`
|
||||
Tools []string `json:"tools"`
|
||||
Config map[string]configKey `json:"config"`
|
||||
Environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
} `json:"environment"`
|
||||
@@ -37,6 +38,18 @@ type manifest struct {
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type configKey struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Pattern string `json:"pattern"`
|
||||
Minimum *float64 `json:"minimum"`
|
||||
Maximum *float64 `json:"maximum"`
|
||||
Predefined string `json:"predefined"`
|
||||
Default any `json:"default"`
|
||||
Required bool `json:"required"`
|
||||
Trusted bool `json:"trusted"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
@@ -99,10 +112,8 @@ func TestItsResourcesAreOneContributionInTheNormalSlotScopedToXTerm(t *testing.T
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xresources" || m.Shell[0].Slot != "normal" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
if m.Shell[0].Code != Resources() {
|
||||
t.Fatal("the manifest's resources are not the ones the bundle describes: regenerate the manifest from Resources()")
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSpace(Resources()), "\n") {
|
||||
code := m.Shell[0].Code
|
||||
for _, l := range strings.Split(strings.TrimSpace(code), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "!"), strings.HasPrefix(l, " "):
|
||||
case strings.HasPrefix(l, "#"):
|
||||
@@ -111,8 +122,8 @@ func TestItsResourcesAreOneContributionInTheNormalSlotScopedToXTerm(t *testing.T
|
||||
t.Errorf("a resource of another program, or of every one: %q", l)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(Resources(), "XTerm*faceName: JetBrainsMono Nerd Font") || !strings.Contains(Resources(), "copy-selection(CLIPBOARD)") {
|
||||
t.Fatal("the chosen face and the clipboard keys")
|
||||
if !strings.Contains(code, "XTerm*faceName: ${config:face}") || !strings.Contains(code, "copy-selection(CLIPBOARD)") {
|
||||
t.Fatal("the face and the clipboard keys")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +213,11 @@ func TestFontAndColoursAreReadAsAnXTermSeesThem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
f := &fake{query: q}
|
||||
got, err := xterm{d: f.desk(true)}.colours(context.Background(), desktop.Args{})
|
||||
rendered := filepath.Join(t.TempDir(), "xresources")
|
||||
if err := os.WriteFile(rendered, []byte(renderedWith(t, nil)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := xterm{d: f.desk(true), resources: rendered}.colours(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,14 +225,14 @@ func TestFontAndColoursAreReadAsAnXTermSeesThem(t *testing.T) {
|
||||
if !strings.Contains(j, `{"name":"color1","value":"#ff0000","default":"#9e1828","differs":true}`) || !strings.Contains(j, `{"name":"foreground","value":"#a8a8a8","default":"#a8a8a8"}`) {
|
||||
t.Fatalf("%s", j)
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"face": "Missing Font"}); err == nil {
|
||||
if _, err := (xterm{d: f.desk(true), resources: rendered}).font(context.Background(), desktop.Args{"face": "Missing Font"}); err == nil {
|
||||
t.Fatal("a face that is not installed")
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"size": 99.0}); err == nil {
|
||||
if _, err := (xterm{d: f.desk(true), resources: rendered}).font(context.Background(), desktop.Args{"size": 99.0}); err == nil {
|
||||
t.Fatal("size out of range")
|
||||
}
|
||||
f.ran, f.stdin = nil, nil
|
||||
got, err = xterm{d: f.desk(true)}.font(context.Background(), desktop.Args{"face": "Inter", "size": 13.0})
|
||||
got, err = xterm{d: f.desk(true), resources: rendered}.font(context.Background(), desktop.Args{"face": "Inter", "size": 13.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they
|
||||
! outrank any `*name` resource of yours and touch no other X program.
|
||||
XTerm*termName: xterm-256color
|
||||
XTerm*saveLines: 10000
|
||||
XTerm*renderFont: true
|
||||
XTerm*faceName: JetBrainsMono Nerd Font
|
||||
XTerm*faceSize: 12
|
||||
XTerm*boldMode: false
|
||||
XTerm*alwaysBoldMode: false
|
||||
XTerm*allowBoldFonts: true
|
||||
XTerm*background: #000000
|
||||
XTerm*foreground: #a8a8a8
|
||||
XTerm*color0: #000000
|
||||
XTerm*color8: #666666
|
||||
XTerm*color1: #9e1828
|
||||
XTerm*color9: #bc5766
|
||||
XTerm*color2: #008800
|
||||
XTerm*color10: #61a171
|
||||
XTerm*color3: #d2bb4b
|
||||
XTerm*color11: #e7db52
|
||||
XTerm*color4: #414171
|
||||
XTerm*color12: #5085af
|
||||
XTerm*color5: #963c59
|
||||
XTerm*color13: #a97a99
|
||||
XTerm*color6: #418179
|
||||
XTerm*color14: #6ba4a4
|
||||
XTerm*color7: #bebebe
|
||||
XTerm*color15: #ffffff
|
||||
XTerm*termName: ${config:term-name}
|
||||
XTerm*saveLines: ${config:save-lines}
|
||||
XTerm*renderFont: ${config:render-font}
|
||||
XTerm*faceName: ${config:face}
|
||||
XTerm*faceSize: ${config:size}
|
||||
XTerm*boldMode: ${config:bold-mode}
|
||||
XTerm*alwaysBoldMode: ${config:always-bold-mode}
|
||||
XTerm*allowBoldFonts: ${config:allow-bold-fonts}
|
||||
XTerm*background: ${config:background}
|
||||
XTerm*foreground: ${config:foreground}
|
||||
XTerm*color0: ${config:color0}
|
||||
XTerm*color8: ${config:color8}
|
||||
XTerm*color1: ${config:color1}
|
||||
XTerm*color9: ${config:color9}
|
||||
XTerm*color2: ${config:color2}
|
||||
XTerm*color10: ${config:color10}
|
||||
XTerm*color3: ${config:color3}
|
||||
XTerm*color11: ${config:color11}
|
||||
XTerm*color4: ${config:color4}
|
||||
XTerm*color12: ${config:color12}
|
||||
XTerm*color5: ${config:color5}
|
||||
XTerm*color13: ${config:color13}
|
||||
XTerm*color6: ${config:color6}
|
||||
XTerm*color14: ${config:color14}
|
||||
XTerm*color7: ${config:color7}
|
||||
XTerm*color15: ${config:color15}
|
||||
XTerm*VT100.Translations: #override \n\
|
||||
Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \n\
|
||||
Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)
|
||||
${config:copy-binding}: copy-selection(CLIPBOARD) \n\
|
||||
${config:paste-binding}: insert-selection(CLIPBOARD)
|
||||
|
||||
@@ -21,6 +21,175 @@
|
||||
"xterm_font",
|
||||
"xterm_colours"
|
||||
],
|
||||
"config": {
|
||||
"face": {
|
||||
"type": "string",
|
||||
"description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).",
|
||||
"pattern": "[A-Za-z0-9 ._-]{1,64}",
|
||||
"default": "JetBrainsMono Nerd Font"
|
||||
},
|
||||
"size": {
|
||||
"type": "number",
|
||||
"description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.",
|
||||
"minimum": 6,
|
||||
"maximum": 72,
|
||||
"predefined": "font-size",
|
||||
"default": 12
|
||||
},
|
||||
"save-lines": {
|
||||
"type": "number",
|
||||
"description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.",
|
||||
"minimum": 0,
|
||||
"maximum": 1000000,
|
||||
"default": 10000
|
||||
},
|
||||
"term-name": {
|
||||
"type": "string",
|
||||
"description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.",
|
||||
"pattern": "[A-Za-z0-9._-]{1,64}",
|
||||
"default": "xterm-256color"
|
||||
},
|
||||
"render-font": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.",
|
||||
"default": true
|
||||
},
|
||||
"bold-mode": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.",
|
||||
"default": false
|
||||
},
|
||||
"always-bold-mode": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.",
|
||||
"default": false
|
||||
},
|
||||
"allow-bold-fonts": {
|
||||
"type": "boolean",
|
||||
"description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.",
|
||||
"default": true
|
||||
},
|
||||
"background": {
|
||||
"type": "string",
|
||||
"description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#000000"
|
||||
},
|
||||
"foreground": {
|
||||
"type": "string",
|
||||
"description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#a8a8a8"
|
||||
},
|
||||
"color0": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#000000"
|
||||
},
|
||||
"color1": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#9e1828"
|
||||
},
|
||||
"color2": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#008800"
|
||||
},
|
||||
"color3": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#d2bb4b"
|
||||
},
|
||||
"color4": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#414171"
|
||||
},
|
||||
"color5": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#963c59"
|
||||
},
|
||||
"color6": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#418179"
|
||||
},
|
||||
"color7": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#bebebe"
|
||||
},
|
||||
"color8": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#666666"
|
||||
},
|
||||
"color9": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#bc5766"
|
||||
},
|
||||
"color10": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#61a171"
|
||||
},
|
||||
"color11": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#e7db52"
|
||||
},
|
||||
"color12": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#5085af"
|
||||
},
|
||||
"color13": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#a97a99"
|
||||
},
|
||||
"color14": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#6ba4a4"
|
||||
},
|
||||
"color15": {
|
||||
"type": "string",
|
||||
"description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.",
|
||||
"pattern": "#[0-9a-fA-F]{6}",
|
||||
"default": "#ffffff"
|
||||
},
|
||||
"copy-binding": {
|
||||
"type": "string",
|
||||
"description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.",
|
||||
"pattern": "[^:\\\\\\n]+",
|
||||
"default": "Ctrl Shift ~Alt <Key> C"
|
||||
},
|
||||
"paste-binding": {
|
||||
"type": "string",
|
||||
"description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.",
|
||||
"pattern": "[^:\\\\\\n]+",
|
||||
"default": "Ctrl Shift ~Alt <Key> V"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"collection": "environment",
|
||||
|
||||
Reference in New Issue
Block a user