Files
stack/tools/install-state-machine.test.sh
T
be-coder-05 3934e03fa6
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/greenfield-install Pipeline was successful
test(installer): make state fixture CI-portable
2026-08-05 14:02:44 -05:00

339 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
# Red-first acceptance checks for #1050. This file is committed before the
# installer implementation. Do not weaken these properties to make it green.
# pass_case always returns zero and fail_case records the aggregate failure;
# the compact A&&pass||fail assertions are intentional.
# shellcheck disable=SC2015
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-state-test.XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
failures=0
COMPAT_BIN="$TMP/compat-bin"
mkdir -p "$COMPAT_BIN"
cat > "$COMPAT_BIN/realpath" <<'REALPATH'
#!/usr/bin/env python3
import os
import sys
args = sys.argv[1:]
mode = args.pop(0) if args and args[0] in ("-e", "-m") else "-m"
if args and args[0] == "--":
args.pop(0)
if len(args) != 1 or (mode == "-e" and not os.path.exists(args[0])):
raise SystemExit(1)
print(os.path.realpath(args[0]))
REALPATH
chmod 0755 "$COMPAT_BIN/realpath"
fail_case() { printf '[test] FAIL: %s\n' "$*" >&2; failures=$((failures + 1)); }
pass_case() { printf '[test] PASS: %s\n' "$*"; }
fingerprint() {
local dir="$1"
if [[ ! -d "$dir" ]]; then printf 'ABSENT\n'; return; fi
python3 - "$dir" <<'PY'
import hashlib
import os
import stat
import sys
root = os.path.abspath(sys.argv[1])
rows = []
for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
for name in dirs + files:
path = os.path.join(current, name)
rel = os.path.relpath(path, root)
meta = os.lstat(path)
target = os.readlink(path) if stat.S_ISLNK(meta.st_mode) else ""
digest = ""
if stat.S_ISREG(meta.st_mode):
with open(path, "rb") as handle:
digest = hashlib.sha256(handle.read()).hexdigest()
rows.append((rel, stat.S_IFMT(meta.st_mode), stat.S_IMODE(meta.st_mode), meta.st_uid, meta.st_gid, target, digest))
payload = "\n".join("|".join(map(str, row)) for row in sorted(rows)).encode()
print(hashlib.sha256(payload).hexdigest())
PY
}
make_fake_npm() {
local bin="$1"
mkdir -p "$bin"
cat > "$bin/npm" <<'FAKE'
#!/bin/bash
set -euo pipefail
if [[ "${1:-}" == "--version" ]]; then echo '10.6.2'; exit 0; fi
case "${1:-} ${2:-} ${3:-}" in
'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;;
'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;;
'view @mosaicstack/mosaic version') echo '0.0.49' ;;
'ls -g --depth=0'|'ls -g --json') echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;;
ls*) echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;;
*) echo "unexpected fake npm command: $*" >&2; exit 1 ;;
esac
FAKE
chmod 0755 "$bin/npm"
}
printf '[test] case: --check enumerates exactly P0-P8, discriminates, and mutates nothing\n'
check_home="$TMP/check-home"
check_bin="$TMP/check-bin"
mkdir -p "$check_home/.config/mosaic/skills/alpha" "$check_home/.npm-global/bin" "$check_bin"
printf '# framework\n' > "$check_home/.config/mosaic/AGENTS.md"
printf '# skill\n' > "$check_home/.config/mosaic/skills/alpha/SKILL.md"
cat > "$check_home/.npm-global/bin/mosaic" <<'CLI'
#!/usr/bin/env bash
printf '0.0.50-next.999\n'
CLI
chmod 0755 "$check_home/.npm-global/bin/mosaic"
make_fake_npm "$check_bin"
before="$(fingerprint "$check_home")"
set +e
HOME="$check_home" MOSAIC_HOME="$check_home/.config/mosaic" MOSAIC_PREFIX="$check_home/.npm-global" \
MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \
bash "$ROOT/tools/install.sh" --check --next >"$TMP/check.log" 2>&1
check_status=$?
set -e
after="$(fingerprint "$check_home")"
[[ "$before" == "$after" ]] && pass_case '--check left the complete HOME fingerprint unchanged' \
|| fail_case "--check mutated HOME (before=$before after=$after)"
[[ "$check_status" -ne 0 ]] && pass_case '--check exited non-zero for failed P4/P5/P8 predicates' \
|| fail_case '--check returned zero on the deliberately broken host'
phase_rows=0
for phase in P0 P1 P2 P3 P4 P5 P6 P7 P8; do
count="$(grep -Ec "^\[$phase\] (PASS|FAIL):" "$TMP/check.log" || true)"
[[ "$count" -eq 1 ]] || fail_case "$phase expected exactly one PASS/FAIL row, got $count"
phase_rows=$((phase_rows + count))
done
[[ "$phase_rows" -eq 9 ]] && pass_case '--check emitted exactly nine P0-P8 result rows' \
|| fail_case "--check emitted $phase_rows canonical rows instead of 9"
grep -q '^\[P3\] PASS:.*0\.0\.50-next\.999' "$TMP/check.log" \
&& pass_case 'P3 preserves the absolute-path exact-version discriminator' \
|| fail_case 'P3 did not PASS with the exact resolved next-lane version'
grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' "$TMP/check.log" \
&& pass_case 'P4 refuses fabricated precision when no shipped-set declaration exists' \
|| fail_case 'P4 did not report the declared-set population as NOT-MEASURED / UNDECLARED'
for phase in P5 P8; do
grep -q "^\[$phase\] FAIL:" "$TMP/check.log" \
&& pass_case "$phase remains an attributable expected RED" \
|| fail_case "$phase did not report its own expected failure"
done
printf '[test] case: --check discriminates a constructed good host without mutation\n'
good_home="$TMP/good-home"
good_bin="$TMP/good-bin"
good_prefix="$good_home/.npm-global"
good_mosaic="$good_home/.config/mosaic"
mkdir -p "$good_bin" "$good_prefix/bin" "$good_mosaic/skills/declared-skill"
make_fake_npm "$good_bin"
cp "$COMPAT_BIN/realpath" "$good_bin/realpath"
cat > "$good_bin/id" <<'ID'
#!/bin/bash
case "${1:-}" in
-u) echo 1001 ;;
-g) echo 1001 ;;
-un) echo fixture-user ;;
*) exec /bin/id "$@" ;;
esac
ID
cat > "$good_bin/stat" <<'STAT'
#!/bin/bash
if [[ "${1:-} ${2:-}" == '-c %u' ]]; then echo 1001; exit 0; fi
exec /bin/stat "$@"
STAT
cat > "$good_bin/curl" <<'CURL'
#!/bin/bash
exit 0
CURL
cat > "$good_bin/ldd" <<'LDD'
#!/bin/bash
echo 'ldd (GNU libc) 2.36'
LDD
chmod 0755 "$good_bin/id" "$good_bin/stat" "$good_bin/curl" "$good_bin/ldd"
cat > "$good_prefix/bin/mosaic" <<'CLI'
#!/usr/bin/env bash
printf '0.0.50-next.999\n'
CLI
chmod 0755 "$good_prefix/bin/mosaic"
cat > "$good_bin/getent" <<GETENT
#!/bin/bash
printf '%s:x:%s:%s::%s:%s\\n' '$(id -un)' '$(id -u)' '$(id -g)' '$good_home' '$good_bin/bash'
GETENT
cat > "$good_bin/bash" <<SHELL
#!/bin/bash
if [[ "\${*: -1}" == 'command -v mosaic' ]]; then
printf '%s\\n' '$good_prefix/bin/mosaic'
exit 0
fi
exec /bin/bash "\$@"
SHELL
chmod 0755 "$good_bin/getent" "$good_bin/bash"
printf '# Soul\n\nConfigured.\n' > "$good_mosaic/SOUL.md"
printf '# User\n\nConfigured.\n' > "$good_mosaic/USER.md"
chmod 0600 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md"
cat > "$good_mosaic/skills/declared-skill/SKILL.md" <<'SKILL'
---
name: declared-skill
description: Constructed loadable acceptance skill.
---
# Declared skill
SKILL
printf '{"lane":"next","version":"0.0.50-next.999","skills":["declared-skill"]}\n' > "$good_mosaic/.install-shipped-skills.json"
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999"\n}\n' > "$good_mosaic/.install-manifest.json"
before="$(fingerprint "$good_home")"
set +e
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
bash "$ROOT/tools/install.sh" --check --next >"$TMP/good-check.log" 2>&1
status=$?
set -e
after="$(fingerprint "$good_home")"
[[ "$status" -eq 0 ]] && pass_case 'good-host --check exited zero' || fail_case "good-host --check exited $status"
[[ "$before" == "$after" ]] && pass_case 'good-host --check left HOME unchanged' || fail_case 'good-host --check mutated HOME'
good_rows="$(grep -Ec '^\[P[0-8]\] PASS:' "$TMP/good-check.log" || true)"
[[ "$good_rows" -eq 9 ]] && pass_case 'good-host --check emitted nine PASS rows' \
|| { cat "$TMP/good-check.log" >&2; fail_case "good-host --check emitted $good_rows PASS rows"; }
printf '[test] case: persisted required-action failures remain blocking\n'
for blocked_phase in P4 P6; do
node -e '
const fs=require("fs"); const p=process.argv[1]; const phase=process.argv[2];
const m=JSON.parse(fs.readFileSync(p,"utf8")); m.phaseOutcomes={P4:"committed",P6:"committed"};
m.phaseOutcomes[phase]="failed"; fs.writeFileSync(p,JSON.stringify(m)+"\n");
' "$good_mosaic/.install-manifest.json" "$blocked_phase"
set +e
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
bash "$ROOT/tools/install.sh" --check --next >"$TMP/action-$blocked_phase.log" 2>&1
status=$?
set -e
[[ "$status" -ne 0 ]] || fail_case "$blocked_phase action failure returned zero"
grep -q "^\[$blocked_phase\] FAIL:.*action reported a required $blocked_phase failure" "$TMP/action-$blocked_phase.log" \
&& pass_case "$blocked_phase action failure remained blocking in a later --check" \
|| fail_case "$blocked_phase persisted action failure was not attributed"
done
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999",\n "phaseOutcomes": {"P4":"committed","P6":"committed"}\n}\n' > "$good_mosaic/.install-manifest.json"
printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n'
for phase in P2 P3 P4 P5 P6 P7 P8; do
home="$TMP/fault-$phase/home"
state="$TMP/fault-$phase/state"
mkdir -p "$home/.config/mosaic" "$home/.npm-global/bin" "$home/.claude" "$state"
printf 'operator-framework-sentinel\n' > "$home/.config/mosaic/operator.txt"
printf '@scope:registry=https://pre.example.invalid/\n' > "$home/.npmrc"
printf 'old-cli\n' > "$home/.npm-global/bin/mosaic"
printf '{"hooks":{"safe":true}}\n' > "$home/.claude/settings.json"
before="$(fingerprint "$home")"
set +e
HOME="$home" MOSAIC_HOME="$home/.config/mosaic" MOSAIC_PREFIX="$home/.npm-global" \
MOSAIC_INSTALL_STATE_DIR="$state" MOSAIC_INSTALL_FAULT_AFTER="$phase" \
MOSAIC_NO_COLOR=1 PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test \
>"$TMP/fault-$phase.log" 2>&1
status=$?
set -e
after="$(fingerprint "$home")"
[[ "$status" -ne 0 ]] || fail_case "$phase injected fault returned zero"
grep -q "phase=$phase" "$TMP/fault-$phase.log" \
|| fail_case "$phase fault transcript did not name the injected phase"
[[ "$before" == "$after" ]] \
&& pass_case "$phase rollback restored framework/npmrc/prefix/runtime representative state" \
|| fail_case "$phase rollback mismatch (before=$before after=$after)"
if find "$state" -type f -exec grep -l '"status"[[:space:]]*:[[:space:]]*"in-progress"' {} + 2>/dev/null | grep -q .; then
fail_case "$phase left a journal in-progress"
else
pass_case "$phase left no journal falsely in-progress"
fi
done
printf '[test] case: unsafe and overlapping rollback roots fail before mutation\n'
unsafe_home="$TMP/unsafe-home"
mkdir -p "$unsafe_home"
for case_name in root-target home-target overlap-target; do
case "$case_name" in
root-target) unsafe_mosaic=/; unsafe_prefix="$unsafe_home/.npm-global" ;;
home-target) unsafe_mosaic="$unsafe_home"; unsafe_prefix="$unsafe_home/.npm-global" ;;
overlap-target) unsafe_mosaic="$unsafe_home/.config"; unsafe_prefix="$unsafe_home/.config/mosaic/prefix" ;;
esac
before="$(fingerprint "$unsafe_home")"
set +e
HOME="$unsafe_home" MOSAIC_HOME="$unsafe_mosaic" MOSAIC_PREFIX="$unsafe_prefix" \
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
bash "$ROOT/tools/install.sh" --check --next >"$TMP/$case_name.log" 2>&1
status=$?
set -e
after="$(fingerprint "$unsafe_home")"
[[ "$status" -ne 0 ]] || fail_case "$case_name unsafe path returned zero"
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/$case_name.log" \
&& pass_case "$case_name was rejected by P0" || fail_case "$case_name lacked an attributable P0 failure"
[[ "$before" == "$after" ]] || fail_case "$case_name mutated HOME"
done
symlink_home="$TMP/symlink-home"
symlink_outside="$TMP/symlink-outside"
mkdir -p "$symlink_home" "$symlink_outside"
ln -s "$symlink_outside" "$symlink_home/.config"
set +e
HOME="$symlink_home" MOSAIC_HOME="$symlink_home/.config/mosaic" MOSAIC_PREFIX="$symlink_home/.npm-global" \
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
bash "$ROOT/tools/install.sh" --check --next >"$TMP/symlink-target.log" 2>&1
status=$?
set -e
[[ "$status" -ne 0 ]] || fail_case 'symlink-parent unsafe path returned zero'
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/symlink-target.log" \
&& pass_case 'symlinked rollback parent was rejected by P0' \
|| fail_case 'symlinked rollback parent lacked an attributable P0 failure'
[[ -z "$(find "$symlink_outside" -mindepth 1 -print -quit)" ]] || fail_case 'symlink target was mutated'
printf '[test] case: stale in-progress projection does not impersonate a live OS lock\n'
stale_home="$TMP/stale/home"
stale_state="$TMP/stale/state"
mkdir -p "$stale_home/.config/mosaic" "$stale_state"
printf '{"status":"in-progress","journal":"%s"}\n' "$stale_state/dead-run/journal.ndjson" > "$stale_state/active.json"
set +e
HOME="$stale_home" MOSAIC_HOME="$stale_home/.config/mosaic" MOSAIC_PREFIX="$stale_home/.npm-global" \
MOSAIC_INSTALL_STATE_DIR="$stale_state" MOSAIC_INSTALL_FAULT_AFTER=P2 MOSAIC_NO_COLOR=1 \
PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test >"$TMP/stale.log" 2>&1
status=$?
set -e
[[ "$status" -eq 97 ]] || fail_case "stale projection recovery expected injected status 97, got $status"
if find "$stale_state" -name prior-active.json -type f -print -quit | grep -q .; then
pass_case 'stale projection was preserved and superseded after the free OS lock was acquired'
else
fail_case 'stale projection was not preserved for recovery evidence'
fi
[[ "$(node -p "require('$stale_state/active.json').status")" == "rolled-back" ]] \
|| fail_case 'stale retry did not reach an honest rolled-back terminal state'
printf '[test] case: journal initialization failure is fatal before mutation\n'
journal_home="$TMP/journal-failure/home"
mkdir -p "$journal_home/.config/mosaic"
printf 'journal-sentinel\n' > "$journal_home/.config/mosaic/operator.txt"
before="$(fingerprint "$journal_home")"
set +e
HOME="$journal_home" MOSAIC_HOME="$journal_home/.config/mosaic" MOSAIC_PREFIX="$journal_home/.npm-global" \
MOSAIC_INSTALL_STATE_DIR="/proc/mosaic-journal-denied-$$" MOSAIC_INSTALL_FAULT_AFTER=P2 \
MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \
>"$TMP/journal-failure.log" 2>&1
status=$?
set -e
after="$(fingerprint "$journal_home")"
[[ "$status" -ne 0 ]] && pass_case 'unwritable journal directory failed non-zero' \
|| fail_case 'unwritable journal directory returned zero'
grep -q 'cannot create private journal directory' "$TMP/journal-failure.log" \
&& pass_case 'journal initialization failure was named' \
|| fail_case 'journal initialization failure lacked a named diagnostic'
[[ "$before" == "$after" ]] && pass_case 'journal failure occurred before target mutation' \
|| fail_case "journal failure mutated target HOME (before=$before after=$after)"
if [[ "$failures" -ne 0 ]]; then
printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2
printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2
exit 1
fi
printf '[test] installer state-machine acceptance passed\n'