#!/usr/bin/env bash # Compile normally; execute unit tests away from real wallets, service buses, # container storage, processes and networking. Never silently fall back to host. set -euo pipefail SCRIPT_REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) REPO=${ARCHY_TEST_REPO:-$SCRIPT_REPO} REPO=$(cd "$REPO" && pwd) if [[ ${ARCHY_TEST_PACKAGE:-archipelago} == gashboard ]]; then [[ $# == 0 ]] || { echo 'Gashboard isolation accepts no command arguments' >&2; exit 2; } [[ -z ${ARCHY_TEST_TERMINAL_FIXTURE:-}${ARCHY_TEST_CLOUD_FIXTURE_CONTAINER:-} && ${ARCHY_TEST_C01_STARTUP:-0} == 0 && ${ARCHY_TEST_CLOUD_COMPILE_ONLY:-0} == 0 && ${ARCHY_TEST_RADIO_OWNER:-0} == 0 ]] || { echo 'Gashboard isolation cannot be combined with another fixture mode' >&2; exit 2; } exec bash "$SCRIPT_REPO/scripts/test-gashboard-isolated.sh" "$REPO" fi if [[ ${ARCHY_TEST_C01_STARTUP:-0} == 1 && ${ARCHY_TEST_ISOLATOR:-systemd} != systemd ]]; then echo 'C01 startup qualification requires the systemd fixture namespace' >&2; exit 2 fi if [[ -n ${ARCHY_TEST_TERMINAL_FIXTURE:-} ]]; then exec bash "$SCRIPT_REPO/scripts/test-terminal-backend-isolated.sh" "$@" fi if [[ -n ${ARCHY_TEST_CLOUD_FIXTURE_CONTAINER:-} || ${ARCHY_TEST_CLOUD_COMPILE_ONLY:-0} == 1 ]]; then [[ ${ARCHY_TEST_ISOLATOR:-systemd} == podman ]] || { echo 'Cloud fixture tests require Podman-isolated compilation; host compilation is forbidden' >&2; exit 2; } exec bash "$SCRIPT_REPO/scripts/test-cloud-backend-isolated.sh" "$@" fi case "${ARCHY_TEST_PACKAGE:-archipelago}" in archipelago) test_target=(-p archipelago --bin archipelago) ;; archipelago-publishing-tests) test_target=(-p archipelago-publishing-tests --lib) ;; archipelago-container) test_target=(-p archipelago-container --lib) ;; archipelago-security) test_target=(-p archipelago-security --lib) ;; *) echo 'Unsupported isolated test package' >&2; exit 2 ;; esac if [[ ${ARCHY_TEST_ISOLATOR:-systemd} == podman ]]; then command -v podman >/dev/null image=${ARCHY_TEST_IMAGE:?ARCHY_TEST_IMAGE is required for podman isolation} cargo_home=${ARCHY_TEST_CARGO_HOME:?ARCHY_TEST_CARGO_HOME is required for podman isolation} cargo_home=$(mkdir -p "$cargo_home" && cd "$cargo_home" && pwd) artifacts=$(mktemp -d) trap 'rm -rf -- "$artifacts"' EXIT install -d "$artifacts/runtime/archipelago" "$artifacts/runtime/containers" \ "$artifacts/runtime/tmp" podman run --rm \ --cpus="${ARCHY_TEST_CPUS:-4}" --memory="${ARCHY_TEST_MEMORY:-4g}" --pids-limit=2048 \ --cap-drop=all --security-opt=no-new-privileges --read-only \ --tmpfs /tmp:rw,size=512m --tmpfs /root:rw,size=512m \ --network=pasta --env CARGO_HOME=/cargo-home \ --volume "$cargo_home:/cargo-home:rw,Z" \ --volume "$REPO:/workspace:rw,Z" --volume "$artifacts:/artifacts:rw,Z" \ --workdir /workspace \ "$image" \ cargo test --manifest-path core/Cargo.toml "${test_target[@]}" \ --locked --no-run --message-format=json \ --config 'profile.test.package.archipelago.opt-level=0' \ --config 'profile.test.package.archipelago.debug=0' \ >"$artifacts/metadata" executable=$(python3 - "$artifacts/metadata" <<'PY' import json,sys found=[] for line in open(sys.argv[1]): try: item=json.loads(line) except json.JSONDecodeError: continue if item.get('reason')=='compiler-artifact' and item.get('profile',{}).get('test') and item.get('executable'): found.append(item['executable']) assert len(found)==1, f'Expected one unit test executable, got {len(found)}' print(found[0]) PY ) case "$executable" in /workspace/*) ;; *) echo 'Compiled test executable escaped the workspace' >&2; exit 1 ;; esac podman run --rm \ --cpus="${ARCHY_TEST_CPUS:-4}" --memory="${ARCHY_TEST_MEMORY:-4g}" --pids-limit=1024 \ --cap-drop=all --cap-add=chown --cap-add=fowner --cap-add=setuid --cap-add=setgid \ --security-opt=no-new-privileges --read-only \ --tmpfs /run:rw,size=64m --tmpfs /root:rw,size=64m \ --tmpfs /var/lib/tor:rw,size=64m --tmpfs /tmp:rw,size=2g --network=none \ --tmpfs /var/lib/archipelago-public-updates:rw --tmpfs /var/lib/archipelago-public-transactions:rw \ --volume "$artifacts/runtime/archipelago:/var/lib/archipelago:rw,Z" \ --volume "$artifacts/runtime/containers:/var/lib/containers:rw,Z" \ --volume "$REPO:/workspace:ro,Z" --workdir /workspace/core \ --env ARCHY_TEST_ISOLATED=1 \ --env ARCHY_TOR_OWNER_FIXTURE=1 \ "$image" "$executable" --test-threads="${ARCHY_TEST_THREADS:-4}" "$@" exit fi [[ ${ARCHY_TEST_ISOLATOR:-systemd} == systemd ]] || { echo 'ARCHY_TEST_ISOLATOR must be systemd or podman' >&2 exit 2 } # Fresh tmpfs mounts isolate both data and free-space accounting. PrivateTmp=yes # would overlay these with private directories on the potentially full host /tmp. test_filesystems='/run:rw /var/lib/archipelago:rw /var/lib/containers:rw /root:rw /var/lib/tor:rw,size=64m /tmp:rw,size=2G,mode=1777 /var/tmp:rw,size=256M,mode=1777' test_filesystems+=' /var/lib/archipelago-public-updates:rw /var/lib/archipelago-public-transactions:rw' startup_environment=() if [[ ${ARCHY_TEST_LOGIN_PASSWORD:-0} == 1 ]]; then # Password owner qualification gets empty account files, never host shadow. test_filesystems+=' /etc:rw,size=16M,mode=755' startup_environment+=(--setenv=ARCHY_TEST_LOGIN_PASSWORD=1) fi if [[ ${ARCHY_TEST_C01_STARTUP:-0} == 1 ]]; then [[ -n ${ARCHY_TEST_C01_BINARY:-} && -x $ARCHY_TEST_C01_BINARY ]] || { echo 'C01 startup qualification requires a freshly compiled non-test binary' >&2; exit 2; } startup_binary=$(realpath -- "$ARCHY_TEST_C01_BINARY") # Hide installed configuration/payloads as well as the ordinary test data. # These are disposable fixture mounts, never C01 installation authority. test_filesystems+=' /opt/archipelago:rw /etc/archipelago:rw' startup_environment+=(--setenv=ARCHY_TEST_C01_STARTUP=1 "--setenv=ARCHY_TEST_C01_BINARY=$startup_binary") fi command -v systemd-run >/dev/null command -v unshare >/dev/null command -v setpriv >/dev/null sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; } metadata=$(mktemp) trap 'rm -f "$metadata"' EXIT if ! cargo test --manifest-path "$REPO/core/Cargo.toml" "${test_target[@]}" \ --locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' --config 'profile.test.package.archipelago.debug=0' > "$metadata"; then python3 - "$metadata" <<'PYDIAG' import json,sys for line in open(sys.argv[1]): try: item=json.loads(line) except json.JSONDecodeError: continue rendered=item.get('message',{}).get('rendered') if item.get('reason')=='compiler-message' else None if rendered: print(rendered,file=sys.stderr,end='') PYDIAG exit 1 fi executable=$(python3 - "$metadata" <<'PY' import json,sys found=[] for line in open(sys.argv[1]): try: item=json.loads(line) except json.JSONDecodeError: continue if item.get('reason')=='compiler-artifact' and item.get('profile',{}).get('test') and item.get('executable'): found.append(item['executable']) assert len(found)==1, f'Expected one unit test executable, got {len(found)}' print(found[0]) PY ) [[ -x "$executable" ]] radio_fixture=() test_command=("$executable" --test-threads="${ARCHY_TEST_THREADS:-4}" "$@") if [[ ${ARCHY_TEST_RADIO_OWNER:-0} == 1 ]]; then test_filesystems+=' /var/lib/archipelago-radio-work:rw,size=256M,mode=700' radio_fixture=(--property=ReadWritePaths=/sys/fs/cgroup "--property=BindReadOnlyPaths=$executable:/opt/radio-test-client" --setenv=ARCHY_RADIO_FIXTURE=1 --setenv=ARCHY_RADIO_CLIENT=/opt/radio-test-client) test_command=(/usr/bin/python3 -B "$REPO/tests/regression/radio-owner.py") fi unit="archy-isolated-tests-$(date +%s)-$$" execution_profile=() if [[ ${ARCHY_TEST_PACKAGE:-archipelago} == archipelago ]]; then execution_profile=(python3 -B "$SCRIPT_REPO/scripts/tests/with_exec_profile.py" "$REPO" --) fi "${execution_profile[@]}" sudo -n systemd-run --unit="$unit" --wait --pipe --collect \ --property=CPUQuota=200% --property=MemoryMax=8G --property=MemorySwapMax=0 \ --property="WorkingDirectory=$REPO/core" \ --property="BindReadOnlyPaths=$REPO" \ --property=PrivateNetwork=yes --property=PrivateTmp=no --property=PrivateDevices=yes \ --property=ProtectSystem=strict --property=ProtectHome=read-only \ --property=NoNewPrivileges=yes \ --property="TemporaryFileSystem=$test_filesystems" \ --setenv=ARCHY_TEST_ISOLATED=1 \ --setenv=ARCHY_TOR_OWNER_FIXTURE=1 \ "--setenv=RUST_MIN_STACK=${RUST_MIN_STACK:-16777216}" \ "${startup_environment[@]}" \ "${radio_fixture[@]}" \ /usr/bin/unshare --pid --fork --mount-proc --kill-child \ /usr/bin/setpriv --bounding-set=-all,+chown,+dac_override,+fowner,+setuid,+setgid,+kill \ "${test_command[@]}"