Compare commits

...
5 changed files with 290 additions and 2 deletions

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
# Row 305 (#1812, Vikunja 591): shell suites remove the compose network they create
Owner Gizmoduck. Reviewer Filbert. Gate owner Sage. Brief:
docs/plans/2026-10-23_gate-env-rows.md, "Brief: shell suites remove the compose network they create
(Vikunja 591)", at c16e943b9. Base c16e943b9. Mos approved the change in mos-1442 point 2. Mos approved
the two new checks in mos-1445 under mos-1272, adding the presence half.
## The leak, as the code has it at c16e943b9
- `packages/processing-control/src/containers.mjs:16-19`. `containerArgs` builds
`docker compose run --rm ...`. `--rm` removes the container. The project's `<project>_default`
network stays.
- `compose.yaml` has no top-level `name:`, so compose names the project after the checkout
directory. Every gate worktree left one network. By 10-23 they had used up Docker's address pools.
## Does COMPOSE_PROJECT_NAME reach compose? Yes, on every path the two suites take
- Task path. `scripts/run-task.sh:29` execs `node scripts/mosaic-task.mjs`. Nothing scrubs the env
before that: `scripts/common.sh` has no `env -i`, no `unset` and no COMPOSE. `mosaic-task.mjs:326`
sets `spawnEnv = { ...process.env }`, `:405` passes `env: spawnEnv` to `runContainer`, and
`containers.mjs:25` passes those options to `spawnSync('docker', ...)`.
- test-release. `release.sh activate` calls `health_check`, and `health_check` calls `run-task.sh`
at `:54`, which is the task path above. `env MOSAIC_MOCK_RESPONSE=...` adds to the env and keeps
the rest. The suite calls only `status`, `activate` and `rollback`, so `release.sh:122`
`compose build` is off its path.
- test-task seat cases (`:240`, `:315-351`). `agent.sh:209` execs
`packages/processing-control/src/container-cli.mjs`, and `container-cli.mjs:16` calls
`runContainer` with `env: process.env`.
- `build.sh:13`, `release.sh:122` and `verify.sh:40` also run compose. Neither suite calls them.
## Change (scripts/test-release.sh, scripts/test-task.sh only)
- After `SANDBOX="$(mktemp -d)"`, export
`COMPOSE_PROJECT_NAME=mosaic-test-<release|task>-<id>`. `<id>` is the sandbox name after its last
dot, lowercased, alphanumeric only.
- `remove_compose_network` runs `docker network rm "${COMPOSE_PROJECT_NAME}_default"`:
- If there's no docker binary, it returns 0.
- If the error says `not found` or `No such network`, it returns 0.
- On any other failure it prints one stderr line naming the network, then returns 0. That
includes a daemon that is down. Sage kept that line in sage-3367 (a).
- Each existing EXIT trap keeps everything it did and gains `; remove_compose_network` at the end.
The trap's own exit status doesn't change the script's.
- One new check per suite, just before the summary. That is after the last Docker step whether or
not the live cases ran. Both halves sit inside a `docker info` branch:
- the network is present under the run's project name before the explicit
`remove_compose_network` (this proves the export reached compose);
- after the remove, it is absent.
A failing `docker network ls` fails the check. Without the daemon, the check prints a skip line.
- No `docker compose down`, and no other network is touched. No product file changes.
Commits: 541dee2b7 (draft), 3bce852d5 (presence half, mos-1445).
## Gate (Sage's slot, in a gap)
- Before the first run, run `docker network rm` on a name that was never created and record the
daemon's exact wording (sage-3367 b). If the wording matches neither pattern, stop and report to
Sage.
- Each suite runs whole from a gate worktree, under `timeout -k 10 900`. That is far more than
3x the row 302 gate's walls: test-release 3 s, test-task 16 s (mos-1275).
- TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXX`.
- Before and after each run, record `docker network ls` filtered to `mosaic-test-`. The run's
network must be absent after.
- Grep `^skip` and name every skip.
- A run killed by the timeout's SIGKILL skips the EXIT trap and leaks its one network. The name is
per-run under `mosaic-test-`, so the gate's after-listing finds it and removes it by its exact name
(Filbert N1; no code change).
- If `docker info` works but there is no network to find, that's a first-run red. It goes to Sage
for Mos, unfixed (mos-1445).
## Mutants (mos-1445)
- (a) Drop the export. The new check must go red, because compose uses the directory's project and
nothing exists under the run's name.
- (b) Make the function's `docker network rm` a no-op. The new check must go red, because the
network is still present after the remove.
- (c) Drop `remove_compose_network` from the trap. This one is a known survivor: the explicit call
covers the normal path, and the trap covers early exits.
Each mutant runs on an isolated copy of the file. Restores are checked by sha256. After each mutant, the network it
leaves is removed by its exact name (mos-1438). Mutant (a) leaves the worktree directory's
`<dir>_default`, and mutant (b) leaves the run's `${COMPOSE_PROJECT_NAME}_default`.
@@ -0,0 +1,88 @@
start 2026-10-23T11:14:01Z HEAD 8ddba89b7 refactor b99ca86b9 docker 29.7.2
== wording
rc=1 stderr: Error response from daemon: network mosaic-test-absent-46829331_default not found
exit status 1
== green
TMPDIR /mnt/storage/scratch/tmp/gIj4
nets[before test-task]:
test-task rc=0 wall 16 s :: selftest: 92 passed, 0 failed
OK compose network made under the run's project and removed after the run
skip: skip live provider cases (row 244: no provider call in the gate)
skip: skip live provider cases (row 244: no provider call in the gate)
nets[after test-task]:
TMPDIR /mnt/storage/scratch/tmp/gDYF
nets[before test-release]:
test-release rc=0 wall 4 s :: selftest: 15 passed, 0 failed
OK compose network made under the run's project and removed after the run
nets[after test-release]:
== mutants
Ma scripts/test-task.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/g6SN
nets[before Ma-task]:
Ma-task rc=1 wall 16 s :: selftest: 91 passed, 1 failed
FAIL compose network made under the run's project and removed after the run
red: FAIL compose network made under the run's project and removed after the run
skip: skip live provider cases (row 244: no provider call in the gate)
skip: skip live provider cases (row 244: no provider call in the gate)
nets[after Ma-task]: gizmo-305_default
restore ok scripts/test-task.sh
Ma-task KILLED
cleaned gizmo-305_default
nets[after cleanup Ma-task]:
Ma scripts/test-release.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/g48Q
nets[before Ma-release]:
Ma-release rc=1 wall 3 s :: selftest: 14 passed, 1 failed
FAIL compose network made under the run's project and removed after the run
red: FAIL compose network made under the run's project and removed after the run
nets[after Ma-release]: gizmo-305_default
restore ok scripts/test-release.sh
Ma-release KILLED
cleaned gizmo-305_default
nets[after cleanup Ma-release]:
Mb scripts/test-task.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/gcm1
nets[before Mb-task]:
Mb-task rc=1 wall 15 s :: selftest: 91 passed, 1 failed
FAIL compose network made under the run's project and removed after the run
red: FAIL compose network made under the run's project and removed after the run
skip: skip live provider cases (row 244: no provider call in the gate)
skip: skip live provider cases (row 244: no provider call in the gate)
nets[after Mb-task]: mosaic-test-task-pf33rz8tic_default
restore ok scripts/test-task.sh
Mb-task KILLED
cleaned mosaic-test-task-pf33rz8tic_default
nets[after cleanup Mb-task]:
Mb scripts/test-release.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/gSxg
nets[before Mb-release]:
Mb-release rc=1 wall 3 s :: selftest: 14 passed, 1 failed
FAIL compose network made under the run's project and removed after the run
red: FAIL compose network made under the run's project and removed after the run
nets[after Mb-release]: mosaic-test-release-db4p70csnf_default
restore ok scripts/test-release.sh
Mb-release KILLED
cleaned mosaic-test-release-db4p70csnf_default
nets[after cleanup Mb-release]:
Mc scripts/test-task.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/g6WJ
nets[before Mc-task]:
Mc-task rc=0 wall 16 s :: selftest: 92 passed, 0 failed
OK compose network made under the run's project and removed after the run
skip: skip live provider cases (row 244: no provider call in the gate)
skip: skip live provider cases (row 244: no provider call in the gate)
nets[after Mc-task]:
restore ok scripts/test-task.sh
Mc-task survived (known)
nets[after cleanup Mc-task]:
Mc scripts/test-release.sh | 2 +-
TMPDIR /mnt/storage/scratch/tmp/g2jz
nets[before Mc-release]:
Mc-release rc=0 wall 3 s :: selftest: 15 passed, 0 failed
OK compose network made under the run's project and removed after the run
nets[after Mc-release]:
restore ok scripts/test-release.sh
Mc-release survived (known)
nets[after cleanup Mc-release]:
tree clean after mutants
end 2026-10-23T11:15:17Z
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Row 305 gate (sage-3374): wording, both suites whole, mutants a/b/c with exact-name cleanup.
set -uo pipefail
WT=/mnt/storage/scratch/gizmo-305
OUT=/mnt/storage/scratch/gizmo-305-out/gate
mkdir -p "$OUT"
cd "$WT" || exit 9
[ "$(git rev-parse HEAD)" = 8ddba89b7442f09fc71cf24d7f250e40a7f60fdf ] || { echo "HEAD drift"; exit 9; }
[ -z "$(git status --porcelain)" ] || { echo "dirty tree"; exit 9; }
[ -L node_modules ] || ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules
[ -L node_modules ] || { echo "no node_modules link"; exit 8; }
DIRNET=gizmo-305_default
nets() { echo " nets[$1]: $(docker network ls --format '{{.Name}}' | grep -E '^(mosaic-test-|gizmo-305_)' | tr '\n' ' ')"; }
newtmp() { export TMPDIR="$(mktemp -d /mnt/storage/scratch/tmp/gXXX)"; echo " TMPDIR $TMPDIR"; }
run() { # name script
local name=$1 s=$2; newtmp; nets "before $name"
local t0=$SECONDS
timeout -k 10 900 "$s" > "$OUT/$name.log" 2>&1; local rc=$?
echo "$name rc=$rc wall $((SECONDS-t0)) s :: $(grep -E '^selftest:' "$OUT/$name.log")"
[ $rc -eq 124 ] || [ $rc -eq 137 ] && echo "SHELL-PHASE TIMEOUT (mos-1275): to Mos unplaced"
grep -E 'compose network' "$OUT/$name.log" | sed 's/^/ /'
grep -E '^FAIL|could not remove' "$OUT/$name.log" | sed 's/^/ red: /'
grep -E '^skip' "$OUT/$name.log" | sed 's/^/ skip: /'
nets "after $name"
LASTRC=$rc
}
echo "start $(date -u +%FT%TZ) HEAD $(git rev-parse --short HEAD) refactor $(git rev-parse --short origin/refactor) docker $(docker version --format '{{.Server.Version}}')"
echo "== wording"
ABS="mosaic-test-absent-$RANDOM$RANDOM"_default
docker network rm "$ABS" >/dev/null 2>"$OUT/absent.txt"; echo " rc=$? stderr: $(cat "$OUT/absent.txt")"
grep -qE 'not found|No such network' "$OUT/absent.txt" || { echo "STOP: wording matches neither pattern"; exit 3; }
echo "== green"
for s in task release; do run "test-$s" "scripts/test-$s.sh"; [ $LASTRC -eq 0 ] || { echo "STOP: red on green run"; exit 4; }; done
nets "after green" | grep -qE 'mosaic-test-|gizmo-305_' && { echo "STOP: network left after green"; exit 5; }
echo "== mutants"
mut() { # tag python-edit expected(red|green)
local tag=$1 edit=$2 want=$3
for s in task release; do
local f="scripts/test-$s.sh" sum; sum=$(sha256sum "$f" | cut -d' ' -f1)
cp -p "$f" "$OUT/$s.orig"
python3 -B -c "$edit" "$f" || { echo "mutant $tag edit failed"; exit 6; }
git diff --stat -- "$f" | head -1 | sed "s/^/ M$tag /"
run "M$tag-$s" "$f"
cp -p "$OUT/$s.orig" "$f"
[ "$(sha256sum "$f" | cut -d' ' -f1)" = "$sum" ] && echo " restore ok $f" || { echo "RESTORE MISMATCH $f"; exit 7; }
if [ "$want" = red ]; then grep -q '^FAIL.*compose network' "$OUT/M$tag-$s.log" && echo " M$tag-$s KILLED" || echo " M$tag-$s SURVIVED (unexpected)"
else grep -q '^OK.*compose network' "$OUT/M$tag-$s.log" && echo " M$tag-$s survived (known)" || echo " M$tag-$s unexpected red"; fi
# exact-name cleanup (mos-1438)
for n in $(docker network ls --format '{{.Name}}' | grep -E '^(mosaic-test-(task|release)-[a-z0-9]+_default|gizmo-305_default)$'); do
docker network rm "$n" >/dev/null && echo " cleaned $n"
done
nets "after cleanup M$tag-$s"
done
}
E='import sys;p=sys.argv[1];s=open(p).read();o=%s;n=%s;assert s.count(o)==1;open(p,"w").write(s.replace(o,n))'
mut a "$(printf "$E" "'export COMPOSE_PROJECT_NAME='" "'COMPOSE_PROJECT_NAME='")" red
mut b "$(printf "$E" "'err=\"\$(docker network rm \"\$net\" 2>&1 >/dev/null)\" && return 0'" "'err=\"\" && return 0'")" red
mut c "$(printf "$E" "'; remove_compose_network'\"'\"' EXIT'" "''\"'\"' EXIT'")" green
[ -z "$(git status --porcelain | grep -v '^?? node_modules$')" ] && echo "tree clean after mutants" || { echo "TREE DIRTY"; git status --porcelain; }
echo "end $(date -u +%FT%TZ)"
+30 -1
View File
@@ -12,7 +12,22 @@ RELEASE_BACKUP="$(mktemp)"
cp RELEASE "$RELEASE_BACKUP"
# One exit trap: the repo RELEASE is ALWAYS restored from the backup,
# regardless of how the test run ends.
trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"' EXIT
# Compose names the project after the checkout directory unless told otherwise,
# and `compose run --rm` leaves the project's <project>_default network behind.
# Every run gets its own project, and the exit trap removes that one network
# (row 305). It never runs `compose down` and never touches another network.
COMPOSE_ID="$(basename "$SANDBOX")"
COMPOSE_ID="$(printf '%s' "${COMPOSE_ID##*.}" | tr -cd '[:alnum:]' | tr '[:upper:]' '[:lower:]')"
export COMPOSE_PROJECT_NAME="mosaic-test-release-$COMPOSE_ID"
remove_compose_network() { # absent is fine; any other failure is one stderr line, never a status
command -v docker >/dev/null 2>&1 || return 0
local net="${COMPOSE_PROJECT_NAME}_default" err
err="$(docker network rm "$net" 2>&1 >/dev/null)" && return 0
case "$err" in *"not found"*|*"No such network"*) return 0 ;; esac
echo "test-release: could not remove network $net: ${err%%$'\n'*}" >&2
return 0
}
trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"; remove_compose_network' EXIT
# Compose mounts PI_AUTH_FILE. Point it at a fixture so no activation reaches
# the host's pi credential (row 244). chmod after the write: umask-proof (row 243).
{ mkdir -p "$SANDBOX/pi" && printf '{}' > "$SANDBOX/pi/auth.json" && chmod 600 "$SANDBOX/pi/auth.json"; } \
@@ -108,6 +123,20 @@ else
echo "skip state-machine cases (docker daemon unavailable)"
fi
# The compose network goes with the run (row 305). Present before the remove
# proves compose used this run's project name; absent after proves the trap's
# cleanup removes it (mos-1445).
if docker info >/dev/null 2>&1; then
NET="${COMPOSE_PROJECT_NAME}_default"
BEFORE="$(docker network ls -q --filter "name=^${NET}\$")" && [ -n "$BEFORE" ] \
&& remove_compose_network \
&& AFTER="$(docker network ls -q --filter "name=^${NET}\$")" && [ -z "$AFTER" ] \
&& check "compose network made under the run's project and removed after the run" 0 \
|| check "compose network made under the run's project and removed after the run" 1
else
echo "skip compose network check (docker daemon unavailable)"
fi
echo
echo "selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
+30 -1
View File
@@ -8,7 +8,22 @@ set -uo pipefail
cd "$(dirname "$0")/.."
SANDBOX="$(mktemp -d)"
trap 'rm -rf "$SANDBOX"' EXIT
# Compose names the project after the checkout directory unless told otherwise,
# and `compose run --rm` leaves the project's <project>_default network behind.
# Every run gets its own project, and the exit trap removes that one network
# (row 305). It never runs `compose down` and never touches another network.
COMPOSE_ID="$(basename "$SANDBOX")"
COMPOSE_ID="$(printf '%s' "${COMPOSE_ID##*.}" | tr -cd '[:alnum:]' | tr '[:upper:]' '[:lower:]')"
export COMPOSE_PROJECT_NAME="mosaic-test-task-$COMPOSE_ID"
remove_compose_network() { # absent is fine; any other failure is one stderr line, never a status
command -v docker >/dev/null 2>&1 || return 0
local net="${COMPOSE_PROJECT_NAME}_default" err
err="$(docker network rm "$net" 2>&1 >/dev/null)" && return 0
case "$err" in *"not found"*|*"No such network"*) return 0 ;; esac
echo "test-task: could not remove network $net: ${err%%$'\n'*}" >&2
return 0
}
trap 'rm -rf "$SANDBOX"; remove_compose_network' EXIT
# Real-model cases need the operator's credential and a billed provider call,
# so they run only on request (row 244, sage-2061). The gate lane never passes
# MOSAIC_LIVE_PROVIDER through.
@@ -558,6 +573,20 @@ fi
git -C "$SANDBOX/repo" rev-parse >/dev/null 2>&1 || true
unset MOSAIC_CONFIG
# The compose network goes with the run (row 305). Present before the remove
# proves compose used this run's project name; absent after proves the trap's
# cleanup removes it (mos-1445).
if docker info >/dev/null 2>&1; then
NET="${COMPOSE_PROJECT_NAME}_default"
BEFORE="$(docker network ls -q --filter "name=^${NET}\$")" && [ -n "$BEFORE" ] \
&& remove_compose_network \
&& AFTER="$(docker network ls -q --filter "name=^${NET}\$")" && [ -z "$AFTER" ] \
&& check "compose network made under the run's project and removed after the run" 0 \
|| check "compose network made under the run's project and removed after the run" 1
else
echo "skip compose network check (docker daemon unavailable)"
fi
echo
echo "selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]