feat(installer): add transactional P0-P9 state machine
This commit is contained in:
+111
-26
@@ -14,6 +14,7 @@ SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}"
|
||||
IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}"
|
||||
GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}"
|
||||
INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}"
|
||||
IN_CLEAN_CONTAINER="${MOSAIC_GREENFIELD_CONTAINER:-0}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -39,38 +40,62 @@ case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expec
|
||||
case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac
|
||||
case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2
|
||||
exit 2
|
||||
if [[ "$IN_CLEAN_CONTAINER" != "1" ]]; then
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
installer_b64=""
|
||||
framework_payload_count="NOT-MEASURED"
|
||||
repo_root_count="NOT-MEASURED"
|
||||
checkout_archive=""
|
||||
checkout_digest=""
|
||||
checkout_content_id=""
|
||||
if [[ "$SOURCE" == "checkout" ]]; then
|
||||
installer_b64="$(base64 -w0 "$INSTALLER_FILE")"
|
||||
[[ -d "$ROOT/packages/mosaic/framework/skills" ]] \
|
||||
&& framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
[[ -d "$ROOT/skills" ]] \
|
||||
&& repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
checkout_archive="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-checkout.XXXXXX.tar.gz")"
|
||||
repo_parent="$(dirname "$ROOT")"
|
||||
repo_name="$(basename "$ROOT")"
|
||||
tar -C "$repo_parent" \
|
||||
--exclude='*/.git' --exclude='*/node_modules' --exclude='*/dist' \
|
||||
--exclude='*/coverage' --exclude='*/.turbo' --exclude='*/.mosaic-test-work' \
|
||||
--exclude='*/.env' --exclude='*/.env.*' \
|
||||
-czf "$checkout_archive" "$repo_name"
|
||||
checkout_digest="$(sha256sum "$checkout_archive" | awk '{print $1}')"
|
||||
checkout_content_id="${checkout_digest:0:40}"
|
||||
fi
|
||||
|
||||
inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")"
|
||||
trap 'rm -f "$inner"' EXIT
|
||||
trap 'rm -f "$inner" "$checkout_archive"' EXIT
|
||||
cat > "$inner" <<'INNER'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
packages=(bash ca-certificates curl jq passwd util-linux)
|
||||
packages=(bash ca-certificates curl jq passwd python3 util-linux)
|
||||
[[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git)
|
||||
apt-get install -y -qq "${packages[@]}" >/dev/null
|
||||
|
||||
if [[ "$FIXTURE_SOURCE" == "checkout" ]]; then
|
||||
awk 'found { print } /^__MOSAIC_CHECKOUT_ARCHIVE__$/ { found=1; next }' "$0" | base64 -d > /tmp/source-checkout.tar.gz
|
||||
actual_checkout_digest="$(sha256sum /tmp/source-checkout.tar.gz | awk '{print $1}')"
|
||||
if [[ "$actual_checkout_digest" != "$FIXTURE_CHECKOUT_SHA256" ]]; then
|
||||
echo "[fixture] checkout archive transport digest mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
useradd --create-home --uid 1001 --shell /bin/bash mosaic
|
||||
install -d -o mosaic -g mosaic /home/mosaic/work
|
||||
|
||||
@@ -130,7 +155,7 @@ fi
|
||||
|
||||
# P1 Preflight
|
||||
missing_tools=()
|
||||
for tool in bash curl git node npm tar; do
|
||||
for tool in bash curl git node npm python3 tar; do
|
||||
command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool")
|
||||
done
|
||||
if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then
|
||||
@@ -173,18 +198,34 @@ printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sy
|
||||
"$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count"
|
||||
if [[ ! -s "$declared_set" ]]; then
|
||||
phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set"
|
||||
elif node - "$declared_set" <<'NODE'
|
||||
elif EXPECTED_LANE="$([[ "$lane" == next ]] && echo next || echo latest)" EXPECTED_VERSION="$resolved_version" \
|
||||
MOSAIC_SKILLS_ROOT="$mosaic_home/skills" node - "$declared_set" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
if (!data || typeof data !== 'object' || !['latest', 'next'].includes(data.lane) ||
|
||||
typeof data.version !== 'string' || !data.version || !Array.isArray(data.skills) || data.skills.length === 0 ||
|
||||
data.skills.some((name) => typeof name !== 'string' || !name)) process.exit(1);
|
||||
const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT);
|
||||
if (!data || data.lane !== process.env.EXPECTED_LANE || data.version !== process.env.EXPECTED_VERSION ||
|
||||
!Array.isArray(data.skills) || data.skills.length === 0) process.exit(1);
|
||||
for (const name of data.skills) {
|
||||
if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1);
|
||||
const skill = path.join(root, name, 'SKILL.md');
|
||||
let real;
|
||||
try { real = fs.realpathSync(skill); } catch { process.exit(1); }
|
||||
const text = fs.readFileSync(real, 'utf8');
|
||||
const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1];
|
||||
if (!real.startsWith(root + path.sep) || !fs.statSync(real).isFile() || !text || declaredName !== name) process.exit(1);
|
||||
}
|
||||
NODE
|
||||
then
|
||||
declared_count="$(node -p "require('$declared_set').skills.length")"
|
||||
phase_pass P4 "declared shipped-set artifact parses (declared_count=$declared_count); C5 owns containment/loadability fulfillment"
|
||||
if [[ -s "$mosaic_home/.install-manifest.json" ]] \
|
||||
&& [[ "$(node -p "require('$mosaic_home/.install-manifest.json').phaseOutcomes?.P4 || 'committed'")" == failed ]]; then
|
||||
phase_fail P4 "declared skills are present but the required framework/skills action reported failure"
|
||||
else
|
||||
phase_pass P4 "declared shipped-set matches lane/version and all $declared_count skill(s) are contained and loadable"
|
||||
fi
|
||||
else
|
||||
phase_fail P4 "NOT-MEASURED / UNDECLARED: shipped-set artifact exists but is empty, malformed, or lacks lane/version"
|
||||
phase_fail P4 "shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable"
|
||||
fi
|
||||
|
||||
# P5 Identity
|
||||
@@ -207,13 +248,20 @@ else
|
||||
fi
|
||||
|
||||
# P6 Runtime linking / activation. #869 must remain unwired without its broker.
|
||||
manifest="$mosaic_home/.install-manifest.json"
|
||||
broker_present=false
|
||||
[[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true
|
||||
dead_hooks=0
|
||||
if [[ -f "$home/.claude/settings.json" ]]; then
|
||||
dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)"
|
||||
fi
|
||||
if [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then
|
||||
p6_action_failed=false
|
||||
if [[ -s "$manifest" ]]; then
|
||||
p6_action_failed="$(node -p "require('$manifest').phaseOutcomes?.P6 === 'failed' ? 'true' : 'false'" 2>/dev/null || echo true)"
|
||||
fi
|
||||
if [[ "$p6_action_failed" == true ]]; then
|
||||
phase_fail P6 "runtime linking/activation action reported a required failure"
|
||||
elif [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then
|
||||
phase_pass P6 "broker absent and #869 enforcement hooks remain inactive"
|
||||
elif [[ "$broker_present" == true ]]; then
|
||||
phase_pass P6 "activation broker present; hook state is evaluable"
|
||||
@@ -257,19 +305,56 @@ exec runuser -u mosaic -- env -i \
|
||||
FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \
|
||||
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \
|
||||
FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE="$([[ "$FIXTURE_SOURCE" == "checkout" ]] && echo /tmp/source-checkout.tar.gz)" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT="$FIXTURE_CHECKOUT_CONTENT_ID" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_SHA256="$FIXTURE_CHECKOUT_SHA256" \
|
||||
/bin/bash /tmp/run-as-target.sh
|
||||
INNER
|
||||
if [[ "$SOURCE" == "checkout" ]]; then
|
||||
{
|
||||
printf '\n__MOSAIC_CHECKOUT_ARCHIVE__\n'
|
||||
base64 "$checkout_archive"
|
||||
} >> "$inner"
|
||||
fi
|
||||
chmod 0755 "$inner"
|
||||
|
||||
printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE"
|
||||
printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n'
|
||||
|
||||
docker run --rm -i \
|
||||
--network bridge \
|
||||
--env FIXTURE_LANE="$LANE" \
|
||||
--env FIXTURE_SOURCE="$SOURCE" \
|
||||
--env FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||
--env FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||
--env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||
--env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||
"$IMAGE" /bin/bash -s < "$inner"
|
||||
if [[ "$IN_CLEAN_CONTAINER" == "1" ]]; then
|
||||
# Woodpecker already supplies the clean Debian container. The target install
|
||||
# still runs through runuser + env -i, so CI variables/credentials do not
|
||||
# enter the target user's process.
|
||||
FIXTURE_LANE="$LANE" \
|
||||
FIXTURE_SOURCE="$SOURCE" \
|
||||
FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||
FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||
FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||
FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||
FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||
/bin/bash "$inner"
|
||||
else
|
||||
# Copy the self-contained script+archive into a stopped container instead of
|
||||
# bind-mounting the checkout or passing host paths. The target runtime still
|
||||
# inherits no host HOME/cache/credentials, and the multi-megabyte checkout
|
||||
# payload avoids argv/environment size limits.
|
||||
fixture_cid="$(docker create \
|
||||
--network bridge \
|
||||
--env FIXTURE_LANE="$LANE" \
|
||||
--env FIXTURE_SOURCE="$SOURCE" \
|
||||
--env FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||
--env FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||
--env FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||
--env FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||
--env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||
--env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||
"$IMAGE" /bin/bash /tmp/mosaic-greenfield-fixture.sh)"
|
||||
docker cp "$inner" "$fixture_cid:/tmp/mosaic-greenfield-fixture.sh"
|
||||
set +e
|
||||
docker start -a "$fixture_cid"
|
||||
fixture_status=$?
|
||||
set -e
|
||||
docker rm "$fixture_cid" >/dev/null
|
||||
exit "$fixture_status"
|
||||
fi
|
||||
|
||||
Executable
+359
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
FAKE_BIN="$TMP/bin"
|
||||
HOME_DIR="$TMP/home"
|
||||
PREFIX="$HOME_DIR/prefix"
|
||||
MOSAIC_HOME="$HOME_DIR/mosaic"
|
||||
STATE="$TMP/state"
|
||||
LOG="$TMP/npm.log"
|
||||
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||
|
||||
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
STATE="${MOSAIC_TEST_STATE:?}"
|
||||
echo "$*" >> "$LOG"
|
||||
|
||||
if [[ "${1:-}" == "--version" ]]; then
|
||||
echo "10.6.2"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
install_cli() {
|
||||
local version="$1"
|
||||
echo "$version" > "$STATE/mosaic"
|
||||
mkdir -p "${MOSAIC_PREFIX:?}/bin"
|
||||
cat > "$MOSAIC_PREFIX/bin/mosaic" <<CLI
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\\n' '$version'
|
||||
CLI
|
||||
chmod +x "$MOSAIC_PREFIX/bin/mosaic"
|
||||
}
|
||||
|
||||
if [[ "$1" == "view" ]]; then
|
||||
if [[ "${MOSAIC_TEST_FAIL_NPM_VIEW:-0}" == "1" ]]; then
|
||||
echo "forced registry metadata failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$2 $3" in
|
||||
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
||||
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
||||
"@mosaicstack/mosaic version") echo "0.0.48" ;;
|
||||
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "install" ]]; then
|
||||
case "$*" in
|
||||
*"@mosaicstack/[email protected]"*)
|
||||
install_cli "0.0.49-next.999"
|
||||
;;
|
||||
*"@mosaicstack/[email protected]"*)
|
||||
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
||||
echo "forced gateway install failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "0.0.7-next.999" > "$STATE/gateway"
|
||||
;;
|
||||
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
||||
install_cli "0.0.0-source"
|
||||
;;
|
||||
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
||||
echo "0.0.0-source" > "$STATE/gateway"
|
||||
;;
|
||||
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "ls" ]]; then
|
||||
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
|
||||
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
|
||||
node -e '
|
||||
const cli = process.argv[1];
|
||||
const gateway = process.argv[2];
|
||||
const dependencies = {};
|
||||
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
|
||||
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
|
||||
process.stdout.write(JSON.stringify({ dependencies }));
|
||||
' "$cli" "$gateway"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected npm command: $*" >&2
|
||||
exit 1
|
||||
FAKE_NPM
|
||||
chmod +x "$FAKE_BIN/npm"
|
||||
|
||||
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
headers=""; output=""; url=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-D) headers="$2"; shift 2 ;;
|
||||
-o) output="$2"; shift 2 ;;
|
||||
--max-filesize) shift 2 ;;
|
||||
-*) shift ;;
|
||||
*) url="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
case "$url" in
|
||||
*/api/v1/repos/mosaicstack/stack/commits?sha=*)
|
||||
printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers"
|
||||
printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output"
|
||||
;;
|
||||
*/archive/*.tar.gz)
|
||||
if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then
|
||||
printf 'not-a-tarball\n' > "$output"
|
||||
else
|
||||
archive_root="$(mktemp -d)"
|
||||
mkdir -p "$archive_root/stack"
|
||||
printf 'fixture\n' > "$archive_root/stack/.fixture"
|
||||
/bin/tar czf "$output" -C "$archive_root" stack
|
||||
rm -rf "$archive_root"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
FAKE_CURL
|
||||
chmod +x "$FAKE_BIN/curl"
|
||||
|
||||
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dest=""; list=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-C) dest="$2"; shift 2 ;;
|
||||
-*t*|t*) list=true; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[[ "$list" == true ]] && exit 0
|
||||
if [[ -z "$dest" ]]; then
|
||||
echo "fake tar missing -C destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
|
||||
FAKE_TAR
|
||||
chmod +x "$FAKE_BIN/tar"
|
||||
|
||||
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
echo "pnpm $*" >> "$LOG"
|
||||
|
||||
if [[ "$1" == "pack" ]]; then
|
||||
out=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pack-destination) out="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$out" ]]; then
|
||||
echo "fake pnpm pack missing destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$out"
|
||||
case "$PWD" in
|
||||
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
|
||||
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
|
||||
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then
|
||||
echo "forced pnpm install failure" >&2
|
||||
exit 42
|
||||
fi
|
||||
|
||||
# Other install/build commands are no-ops in this harness.
|
||||
exit 0
|
||||
FAKE_PNPM
|
||||
chmod +x "$FAKE_BIN/pnpm"
|
||||
|
||||
reset_state() {
|
||||
: > "$LOG"
|
||||
rm -f "$STATE"/*
|
||||
}
|
||||
|
||||
prefix_fingerprint() {
|
||||
if [[ ! -d "$PREFIX" ]]; then printf 'ABSENT\n'; return; fi
|
||||
(
|
||||
cd "$PREFIX"
|
||||
find . -mindepth 1 -printf '%P|%y|%m|%l\n' | LC_ALL=C sort
|
||||
find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum
|
||||
) | sha256sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
reset_state
|
||||
echo "[test] --next fast path pins resolved package versions"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
|
||||
echo "expected exact-version installs, found mutable @next install" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then
|
||||
echo "fast path unexpectedly fell back to source" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]]
|
||||
JOURNAL="$(node -p "require('$ACTIVE').journal")"
|
||||
[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]]
|
||||
( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null )
|
||||
grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL"
|
||||
|
||||
reset_state
|
||||
echo "[test] fast path failure falls back to source build"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
||||
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
||||
grep -qF 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
|
||||
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
||||
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] source-build failure is fatal and restores the pre-install prefix"
|
||||
before_prefix="$(prefix_fingerprint)"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"
|
||||
FAIL_STATUS=$?
|
||||
set -e
|
||||
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||
grep -qF 'forced pnpm install failure' <<<"$OUTPUT"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] corrupt source archive is fatal and restores the pre-install prefix"
|
||||
before_prefix="$(prefix_fingerprint)"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
MOSAIC_TEST_CORRUPT_ARCHIVE=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"
|
||||
FAIL_STATUS=$?
|
||||
set -e
|
||||
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||
grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] --dev source install does not require registry version resolution"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NPM_VIEW=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch
|
||||
)"
|
||||
grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||
|
||||
reset_state
|
||||
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
||||
)"
|
||||
CHECK_STATUS=$?
|
||||
set -e
|
||||
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||
if grep -qF '@next version' "$LOG"; then
|
||||
echo "explicit ref should not query @next dist-tags" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reset_state
|
||||
echo "[test] --check --next rejects mismatched prerelease pipeline suffixes"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next
|
||||
)"
|
||||
CHECK_STATUS=$?
|
||||
set -e
|
||||
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||
grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT"
|
||||
|
||||
echo "[test] installer next lane tests passed"
|
||||
@@ -2,6 +2,9 @@
|
||||
# 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)"
|
||||
@@ -26,8 +29,9 @@ make_fake_npm() {
|
||||
local bin="$1"
|
||||
mkdir -p "$bin"
|
||||
cat > "$bin/npm" <<'FAKE'
|
||||
#!/usr/bin/env bash
|
||||
#!/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' ;;
|
||||
@@ -86,6 +90,78 @@ for phase in P5 P8; do
|
||||
|| 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"
|
||||
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"
|
||||
@@ -117,6 +193,86 @@ for phase in P2 P3 P4 P5 P6 P7 P8; do
|
||||
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="$check_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="$check_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 \
|
||||
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
|
||||
|
||||
+1148
-61
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user