361 lines
16 KiB
Bash
Executable File
361 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Greenfield installer acceptance fixture.
|
|
#
|
|
# The fixture itself is intentionally RED until the C2-C5 phase owners repair
|
|
# their postconditions. C1's CI gate executes it and validates that the RED is
|
|
# attributable (including the discriminating P3 PASS); it does not turn the
|
|
# failed install into a false green.
|
|
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
LANE="${MOSAIC_INSTALL_LANE:-next}"
|
|
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'
|
|
Usage: tools/e2e-install-test.sh [--lane next|main] [--source checkout|remote] [--git present|absent]
|
|
|
|
Runs the documented installer command from zero in Debian/glibc as a non-root
|
|
uid with an isolated HOME. The fixture exits non-zero when any P0-P8
|
|
postcondition fails. `next` is always selected with the --next installer flag.
|
|
EOF
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--lane) LANE="${2:-}"; shift 2 ;;
|
|
--source) SOURCE="${2:-}"; shift 2 ;;
|
|
--git) GIT_MODE="${2:-}"; shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "[fixture] unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expected next|main)" >&2; exit 2 ;; esac
|
|
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 [[ "$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" "$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 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
|
|
|
|
case "$FIXTURE_SOURCE" in
|
|
checkout)
|
|
printf '%s' "$FIXTURE_INSTALLER_B64" | base64 -d > /tmp/install.sh
|
|
;;
|
|
remote)
|
|
curl -fsSL "https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/${FIXTURE_LANE}/tools/install.sh" > /tmp/install.sh
|
|
;;
|
|
esac
|
|
chmod 0755 /tmp/install.sh
|
|
sha256sum /tmp/install.sh | sed 's/^/[fixture] installer sha256: /'
|
|
|
|
cat > /tmp/run-as-target.sh <<'TARGET'
|
|
#!/usr/bin/env bash
|
|
set -uo pipefail
|
|
|
|
lane="$FIXTURE_LANE"
|
|
home="$HOME"
|
|
prefix="$home/.npm-global"
|
|
mosaic_home="$home/.config/mosaic"
|
|
install_log="$home/install.log"
|
|
failures=0
|
|
|
|
phase_pass() { printf '[%s] PASS: %s\n' "$1" "$2"; }
|
|
phase_fail() { printf '[%s] FAIL: %s\n' "$1" "$2"; failures=$((failures + 1)); }
|
|
|
|
lane_args=()
|
|
resolved_spec='@mosaicstack/mosaic'
|
|
if [[ "$lane" == "next" ]]; then
|
|
lane_args+=(--next)
|
|
resolved_spec='@mosaicstack/mosaic@next'
|
|
fi
|
|
|
|
resolved_version="$(npm view "$resolved_spec" version --registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ 2>/dev/null || true)"
|
|
printf '[fixture] resolved lane=%s package=%s version=%s\n' "$lane" "$resolved_spec" "${resolved_version:-UNRESOLVED}"
|
|
|
|
set +e
|
|
MOSAIC_NO_COLOR=1 MOSAIC_ASSUME_YES=1 \
|
|
bash /tmp/install.sh "${lane_args[@]}" --yes --no-auto-launch >"$install_log" 2>&1
|
|
install_status=$?
|
|
set -e
|
|
cat "$install_log"
|
|
printf '[fixture] installer_exit=%d done_claims=%s\n' \
|
|
"$install_status" "$(grep -cF 'Done.' "$install_log" || true)"
|
|
|
|
# P0 Resolve context
|
|
shell="$(getent passwd "$(id -u)" | cut -d: -f7)"
|
|
if [[ "$(id -u)" -ne 0 && "$home" == "/home/mosaic" && "$shell" == "/bin/bash" ]] \
|
|
&& ldd --version 2>&1 | grep -i 'glibc\|gnu libc' >/dev/null \
|
|
&& [[ "$(node -p 'Number(process.versions.node.split(".")[0])')" -ge 20 ]]; then
|
|
phase_pass P0 "target=mosaic uid=$(id -u) HOME=$home shell=$shell libc=glibc node=$(node --version)"
|
|
else
|
|
phase_fail P0 "context unresolved or unsupported (uid=$(id -u) HOME=$home shell=${shell:-unknown})"
|
|
fi
|
|
|
|
# P1 Preflight
|
|
missing_tools=()
|
|
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
|
|
phase_pass P1 "required tools present (including downstream git); target HOME writable; registry lane resolved"
|
|
else
|
|
phase_fail P1 "undeclared/missing prerequisite(s)=${missing_tools[*]:-none}; target_writable=$([[ -w "$home" ]] && echo yes || echo no) registry_resolved=$([[ -n "$resolved_version" ]] && echo yes || echo no)"
|
|
fi
|
|
|
|
# P2 Acquire artifacts
|
|
if [[ -n "$resolved_version" ]] && grep -qF "$resolved_version" "$install_log"; then
|
|
phase_pass P2 "lane=$lane pinned_version=$resolved_version recorded in installer transcript"
|
|
else
|
|
phase_fail P2 "lane=$lane did not resolve and record a pinned artifact version"
|
|
fi
|
|
|
|
# P3 Install CLI — the discriminating row. Use the known absolute path only.
|
|
cli="$prefix/bin/mosaic"
|
|
cli_version=""
|
|
if [[ -x "$cli" ]]; then
|
|
cli_version="$($cli --version 2>/dev/null | tail -n 1 | tr -d '\r' || true)"
|
|
fi
|
|
if [[ -x "$cli" && "$cli_version" == "$resolved_version" ]]; then
|
|
phase_pass P3 "absolute_path=$cli version=$cli_version equals resolved lane version"
|
|
else
|
|
phase_fail P3 "absolute_path=$cli executable=$([[ -x "$cli" ]] && echo yes || echo no) got=${cli_version:-missing} expected=${resolved_version:-unresolved}"
|
|
fi
|
|
|
|
# P4 Framework + skills. C1 does not choose among the four disagreeing
|
|
# candidate populations. It requires the installer to publish a lane/versioned
|
|
# shipped-set declaration that a checkout-free install can resolve; C5 owns its
|
|
# contents. Without that artifact P4 is NOT-MEASURED, never a fabricated count.
|
|
declared_set="$mosaic_home/.install-shipped-skills.json"
|
|
sync_store_count=0
|
|
runtime_link_count=0
|
|
[[ -d "$mosaic_home/skills" ]] \
|
|
&& sync_store_count="$(find "$mosaic_home/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
|
[[ -d "$home/.pi/agent/skills" ]] \
|
|
&& runtime_link_count="$(find "$home/.pi/agent/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) | wc -l | tr -d ' ')"
|
|
printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sync_store=%s jarvis_W-jarvis_observation=7 runtime_links=%s\n' \
|
|
"$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 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'));
|
|
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")"
|
|
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 "shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable"
|
|
fi
|
|
|
|
# P5 Identity
|
|
identity_ok=true
|
|
identity_reason=()
|
|
for f in SOUL.md USER.md; do
|
|
path="$mosaic_home/$f"
|
|
if [[ ! -s "$path" ]]; then
|
|
identity_ok=false; identity_reason+=("$f missing-or-empty"); continue
|
|
fi
|
|
owner="$(stat -c '%u' "$path")"; mode="$(stat -c '%a' "$path")"
|
|
if [[ "$owner" != "$(id -u)" || "$mode" =~ [2367]$ ]]; then
|
|
identity_ok=false; identity_reason+=("$f owner=$owner mode=$mode")
|
|
fi
|
|
done
|
|
if [[ "$identity_ok" == true ]]; then
|
|
phase_pass P5 "SOUL.md and USER.md are non-empty and target-user owned with non-world-writable modes"
|
|
else
|
|
phase_fail P5 "${identity_reason[*]}"
|
|
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
|
|
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"
|
|
else
|
|
phase_fail P6 "broker absent but dead enforcement hooks are active (count=$dead_hooks)"
|
|
fi
|
|
|
|
# P7 Services — none requested by --no-auto-launch.
|
|
phase_pass P7 "no services requested by this fixture"
|
|
|
|
# P8 Shell discoverability — actual target shell, fresh login and non-login.
|
|
base_env=(env -i HOME="$home" USER=mosaic LOGNAME=mosaic SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin)
|
|
login_path="$("${base_env[@]}" /bin/bash -lc 'command -v mosaic' 2>/dev/null || true)"
|
|
nonlogin_path="$("${base_env[@]}" /bin/bash -c 'command -v mosaic' 2>/dev/null || true)"
|
|
if [[ "$login_path" == "$cli" && "$nonlogin_path" == "$cli" ]]; then
|
|
phase_pass P8 "login=$login_path nonlogin=$nonlogin_path equals P3 path"
|
|
else
|
|
phase_fail P8 "fresh bash login=${login_path:-missing} nonlogin=${nonlogin_path:-missing} expected=$cli"
|
|
fi
|
|
|
|
manifest="$mosaic_home/.install-manifest.json"
|
|
p0_p8_failures="$failures"
|
|
if [[ "$p0_p8_failures" -eq 0 && -s "$manifest" ]]; then
|
|
phase_pass P9 "P0-P8 reasserted; manifest present"
|
|
else
|
|
phase_fail P9 "P0-P8_failed_postconditions=$p0_p8_failures manifest=$([[ -s "$manifest" ]] && echo present || echo missing); install must not certify success"
|
|
fi
|
|
|
|
printf '[fixture] P0-P9_failed_rows=%d (includes P9 aggregate row)\n' "$failures"
|
|
if [[ "$failures" -ne 0 ]]; then
|
|
exit 1
|
|
fi
|
|
TARGET
|
|
chmod 0755 /tmp/run-as-target.sh
|
|
chown mosaic:mosaic /tmp/run-as-target.sh
|
|
|
|
exec runuser -u mosaic -- env -i \
|
|
HOME=/home/mosaic USER=mosaic LOGNAME=mosaic SHELL=/bin/bash \
|
|
PATH=/usr/local/bin:/usr/bin:/bin \
|
|
FIXTURE_LANE="$FIXTURE_LANE" \
|
|
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'
|
|
|
|
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
|