From e69b30bb827da91fa8378c327bc9861d24d82320 Mon Sep 17 00:00:00 2001 From: yyjeqhc <1772413353@qq.com> Date: Wed, 26 Aug 2026 08:17:23 +0800 Subject: [PATCH] Make Docker Server bootstrap recoverable --- .gitignore | 1 + deploy/docker/bootstrap.sh | 851 +++++++++++++++--- docs/DEPLOYMENT.md | 33 +- docs/DEPLOYMENT.zh-CN.md | 28 +- scripts/prepare_server_deployment_assets.py | 26 +- scripts/tests/test_release_publication.py | 6 +- scripts/tests/test_server_docker_bootstrap.py | 446 +++++++-- 7 files changed, 1200 insertions(+), 191 deletions(-) diff --git a/.gitignore b/.gitignore index 4ff8f29b..95ba3ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ data* # They contain real server URLs, tokens, and host paths. If a token was ever # committed or exposed, rotate WEBCODEX_TOKEN immediately. /.env +/.webcodex-bootstrap.receipt /agent.toml /projects.d/ /*.local.toml diff --git a/deploy/docker/bootstrap.sh b/deploy/docker/bootstrap.sh index 61702d48..770d97fb 100755 --- a/deploy/docker/bootstrap.sh +++ b/deploy/docker/bootstrap.sh @@ -1,113 +1,547 @@ #!/bin/sh set -eu +ENV_FILE=.env +RECEIPT_FILE=.webcodex-bootstrap.receipt +RECEIPT_VERSION=1 +BUILD_OVERLAY=compose.build.yaml +HOST_IP=127.0.0.1 +HOST_PORT=8080 +ZERO_TOKEN=0000000000000000000000000000000000000000000000000000000000000000 +HEALTH_WAIT_SECS=${WEBCODEX_BOOTSTRAP_HEALTH_WAIT_SECS:-90} +TEMP_FILES= + +cleanup_temps() { + for path in $TEMP_FILES; do + rm -f "$path" + done +} +trap cleanup_temps EXIT +trap 'cleanup_temps; exit 130' HUP INT TERM + +fail() { + echo "$*" >&2 + exit 1 +} + usage() { - echo "Usage: $0 [--build-from-source]" >&2 - echo "Example: $0 https://webcodex.example.com" >&2 - echo "Source build: $0 https://webcodex.example.com --build-from-source" >&2 + cat >&2 < [--build-from-source] + $0 status + $0 resume + $0 rollback + +Examples: + $0 https://webcodex.example.com + $0 https://webcodex.example.com --build-from-source + $0 status + $0 resume + $0 rollback +EOF_USAGE exit 2 } -[ "$#" -ge 1 ] && [ "$#" -le 2 ] || usage -PUBLIC_URL=${1%/} -BUILD_FROM_SOURCE=false -if [ "$#" -eq 2 ]; then - [ "$2" = "--build-from-source" ] || usage - BUILD_FROM_SOURCE=true -fi -COMPOSE_FILE=${COMPOSE_FILE:-compose.yaml} -SERVER_IMAGE=${WEBCODEX_SERVER_IMAGE:-} +validate_port_number() { + value=$1 + case "$value" in + ""|*[!0-9]*) return 1 ;; + esac + [ "$value" -ge 1 ] 2>/dev/null && [ "$value" -le 65535 ] 2>/dev/null +} -case "$PUBLIC_URL" in - https://*) ;; - *) - echo "public URL must start with https://" >&2 - exit 2 - ;; -esac +validate_ipv4() { + value=$1 + old_ifs=$IFS + IFS=. + set -- $value + IFS=$old_ifs + [ "$#" -eq 4 ] || return 1 + for octet in "$@"; do + case "$octet" in + ""|*[!0-9]*) return 1 ;; + esac + [ "$octet" -le 255 ] 2>/dev/null || return 1 + done +} -ORIGIN=${PUBLIC_URL#https://} -case "$ORIGIN" in - ""|*/*) - echo "public URL must be an HTTPS origin without a path" >&2 - exit 2 - ;; -esac +validate_dns_name() { + value=$1 + [ -n "$value" ] || return 1 + [ "${#value}" -le 253 ] || return 1 + case "$value" in + .*|*.|*..*) return 1 ;; + esac + old_ifs=$IFS + IFS=. + set -- $value + IFS=$old_ifs + for label in "$@"; do + [ -n "$label" ] && [ "${#label}" -le 63 ] || return 1 + case "$label" in + -*|*-|*[!A-Za-z0-9-]*) return 1 ;; + esac + done +} -if [ -e .env ]; then - echo ".env already exists; refusing to overwrite it" >&2 - exit 1 -fi +count_ipv6_groups() { + value=$1 + if [ -z "$value" ]; then + printf '0\n' + return 0 + fi + old_ifs=$IFS + IFS=: + set -- $value + IFS=$old_ifs + raw_count=$# + effective_count=0 + index=0 + for group in "$@"; do + index=$((index + 1)) + case "$group" in + *.*) + [ "$index" -eq "$raw_count" ] || return 1 + validate_ipv4 "$group" || return 1 + effective_count=$((effective_count + 2)) + ;; + ""|?????*|*[!0-9A-Fa-f]*) return 1 ;; + *) effective_count=$((effective_count + 1)) ;; + esac + done + printf '%s\n' "$effective_count" +} -if ! command -v docker >/dev/null 2>&1; then - echo "docker is required" >&2 - exit 1 -fi +validate_ipv6() { + value=$1 + [ -n "$value" ] || return 1 + case "$value" in + *:::*) return 1 ;; + esac + case "$value" in + *::*) + left=${value%%::*} + right=${value#*::} + case "$right" in + *::*) return 1 ;; + esac + left_count=$(count_ipv6_groups "$left") || return 1 + right_count=$(count_ipv6_groups "$right") || return 1 + [ $((left_count + right_count)) -lt 8 ] + ;; + *) + count=$(count_ipv6_groups "$value") || return 1 + [ "$count" -eq 8 ] + ;; + esac +} -case "$COMPOSE_FILE" in - ""|/*|-*|../*|*/../*|*/..|*[!A-Za-z0-9._/-]*) - echo "invalid COMPOSE_FILE: expected a safe relative path" >&2 - exit 2 - ;; -esac -if [ ! -f "$COMPOSE_FILE" ]; then - echo "compose file not found: $COMPOSE_FILE" >&2 - exit 1 -fi +validate_public_url() { + value=$1 + case "$value" in + *[[:space:][:cntrl:]]*) + echo "public URL must not contain whitespace or control characters" >&2 + return 1 + ;; + esac + case "$value" in + https://*) ;; + *) + echo "public URL must be an HTTPS origin" >&2 + return 1 + ;; + esac + authority=${value#https://} + [ -n "$authority" ] || { + echo "public URL must contain a host" >&2 + return 1 + } + case "$authority" in + */*|*\?*|*\#*|*@*) + echo "public URL must be an HTTPS origin without userinfo, path, query, or fragment" >&2 + return 1 + ;; + esac + + port= + case "$authority" in + \[*\]*) + bracket_host=${authority%%]*} + host=${bracket_host#\[} + rest=${authority#"$bracket_host"} + rest=${rest#]} + case "$rest" in + "") ;; + :*) port=${rest#:} ;; + *) + echo "public URL has invalid bracketed IPv6 authority" >&2 + return 1 + ;; + esac + validate_ipv6 "$host" || { + echo "public URL has invalid bracketed IPv6 host" >&2 + return 1 + } + ;; + *:*) + host=${authority%%:*} + port=${authority#*:} + case "$port" in + *:*) + echo "IPv6 public URL hosts must be bracketed" >&2 + return 1 + ;; + esac + ;; + *) host=$authority ;; + esac -compose() { + if [ "${authority#\[}" = "$authority" ]; then + case "$host" in + "") + echo "public URL must contain a host" >&2 + return 1 + ;; + *[!0-9.]* ) + validate_dns_name "$host" || { + echo "public URL has invalid DNS host" >&2 + return 1 + } + ;; + *) + validate_ipv4 "$host" || { + echo "public URL has invalid IPv4 host" >&2 + return 1 + } + ;; + esac + fi + + if [ -n "$port" ]; then + validate_port_number "$port" || { + echo "public URL port must be in 1..65535" >&2 + return 1 + } + fi +} + +safe_compose_file() { + value=$1 + case "$value" in + ""|/*|-*|../*|*/../*|*/..|*[!A-Za-z0-9._/-]*) return 1 ;; + *) return 0 ;; + esac +} + +validate_server_image() { + value=$1 + case "$value" in + ""|*[!A-Za-z0-9._/:@-]*) return 1 ;; + *) return 0 ;; + esac +} + +sha256_file() { + path=$1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + else + return 127 + fi +} + +valid_sha256_or_dash() { + value=$1 + [ "$value" = - ] && return 0 + [ "${#value}" -eq 64 ] || return 1 + case "$value" in + *[!0-9a-f]*) return 1 ;; + *) return 0 ;; + esac +} + +atomic_commit() { + target=$1 + tmp=$2 + chmod 600 "$tmp" + if ! sync "$tmp"; then + echo "failed to fsync temporary bootstrap state: $tmp" >&2 + return 1 + fi + if ! mv "$tmp" "$target"; then + echo "failed to atomically commit bootstrap state: $target" >&2 + return 1 + fi + TEMP_FILES= +} + +write_receipt() { + next_phase=$1 + next_env_sha=$2 + tmp="$RECEIPT_FILE.$$.tmp" + TEMP_FILES=$tmp + umask 077 + cat > "$tmp" </dev/null 2>&1; then - echo "docker compose v2 is required" >&2 - exit 1 -fi +compose_full() { + if [ "$MODE" = source ]; then + docker compose -f "$COMPOSE_FILE" -f "$BUILD_OVERLAY" "$@" + else + docker compose -f "$COMPOSE_FILE" "$@" + fi +} + +require_runtime_dependencies() { + command -v docker >/dev/null 2>&1 || fail "docker is required" + docker compose version >/dev/null 2>&1 || fail "docker compose v2 is required" + command -v sync >/dev/null 2>&1 || fail "sync is required for durable bootstrap state commits" + if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then + fail "sha256sum or shasum is required" + fi +} -if [ "$BUILD_FROM_SOURCE" = false ]; then - if [ -z "$SERVER_IMAGE" ]; then - SERVER_IMAGE=$(WEBCODEX_TOKEN=0000000000000000000000000000000000000000000000000000000000000000 \ - WEBCODEX_PUBLIC_URL="$PUBLIC_URL" \ - compose config --images) +verify_files_against_receipt() { + [ -f "$COMPOSE_FILE" ] || fail "compose file recorded by receipt is missing: $COMPOSE_FILE" + actual=$(sha256_file "$COMPOSE_FILE") || fail "could not hash compose file" + [ "$actual" = "$COMPOSE_DIGEST" ] || fail "compose file digest does not match installation receipt" + if [ "$MODE" = source ]; then + [ -f "$BUILD_OVERLAY" ] || fail "$BUILD_OVERLAY recorded by receipt is missing" + actual=$(sha256_file "$BUILD_OVERLAY") || fail "could not hash source build overlay" + [ "$actual" = "$OVERLAY_DIGEST" ] || fail "source build overlay digest does not match installation receipt" + fi + if [ "$PHASE" != AssetsPrepared ]; then + [ -f "$ENV_FILE" ] && [ ! -L "$ENV_FILE" ] || fail "$ENV_FILE recorded by receipt is missing or unsafe" + actual=$(sha256_file "$ENV_FILE") || fail "could not hash $ENV_FILE" + [ "$actual" = "$ENV_DIGEST" ] || fail "$ENV_FILE fingerprint does not match installation receipt" fi - case "$SERVER_IMAGE" in - ""|*[!A-Za-z0-9._/:@-]*) +} + +validate_committed_env() { + [ -f "$ENV_FILE" ] && [ ! -L "$ENV_FILE" ] || fail "$ENV_FILE is missing or unsafe" + if [ "$MODE" = image ]; then + expected_lines=8 + else + expected_lines=7 + fi + lines=$(wc -l < "$ENV_FILE" | tr -d ' ') + [ "$lines" -eq "$expected_lines" ] || fail "$ENV_FILE does not match the canonical bootstrap layout" + for expected in \ + "WEBCODEX_PUBLIC_URL=$PUBLIC_URL" \ + "WEBCODEX_HOST_IP=$HOST_IP" \ + "WEBCODEX_HOST_PORT=$HOST_PORT" \ + "RUST_LOG=info" \ + "WEBCODEX_MCP_MODEL_SURFACE=local-coding-v1" \ + "COMPOSE_FILE=$COMPOSE_FILE"; do + [ "$(grep -Fxc "$expected" "$ENV_FILE" || true)" -eq 1 ] \ + || fail "$ENV_FILE does not match the installation receipt" + done + if [ "$MODE" = image ]; then + [ "$(grep -Fxc "WEBCODEX_SERVER_IMAGE=$SERVER_IMAGE" "$ENV_FILE" || true)" -eq 1 ] \ + || fail "$ENV_FILE does not match the recorded Server image" + elif grep -q '^WEBCODEX_SERVER_IMAGE=' "$ENV_FILE"; then + fail "$ENV_FILE unexpectedly contains a Server image for source mode" + fi + [ "$(grep -c '^WEBCODEX_TOKEN=' "$ENV_FILE" || true)" -eq 1 ] \ + || fail "$ENV_FILE does not contain exactly one administrator token" + token=$(sed -n 's/^WEBCODEX_TOKEN=//p' "$ENV_FILE") + [ "${#token}" -eq 64 ] || fail "$ENV_FILE administrator token has an invalid length" + case "$token" in + *[!0-9a-f]*) fail "$ENV_FILE administrator token is not lowercase hex" ;; + esac +} + +reconcile_secret_commit_if_needed() { + [ "$PHASE" = AssetsPrepared ] || return 0 + [ -e "$ENV_FILE" ] || return 0 + validate_committed_env + ENV_DIGEST=$(sha256_file "$ENV_FILE") || fail "could not fingerprint committed $ENV_FILE" + write_receipt SecretCommitted "$ENV_DIGEST" +} + +port_preflight() { + if command -v ss >/dev/null 2>&1; then + if ss -H -ltn "sport = :$HOST_PORT" 2>/dev/null | grep -q .; then + fail "$HOST_IP:$HOST_PORT is already listening; free the port before retrying" + fi + return 0 + fi + if command -v netstat >/dev/null 2>&1; then + if netstat -ltn 2>/dev/null | grep -E "[.:]${HOST_PORT}[[:space:]]" >/dev/null; then + fail "$HOST_IP:$HOST_PORT is already listening; free the port before retrying" + fi + return 0 + fi + fail "ss or netstat is required for host port preflight" +} + +preflight_fresh_install() { + validate_public_url "$PUBLIC_URL" || exit 2 + safe_compose_file "$COMPOSE_FILE" || { + echo "invalid COMPOSE_FILE: expected a safe relative path" >&2 + exit 2 + } + [ -f "$COMPOSE_FILE" ] || fail "compose file not found: $COMPOSE_FILE" + [ ! -e "$RECEIPT_FILE" ] || fail "installation receipt already exists; use '$0 status', '$0 resume', or '$0 rollback'" + [ ! -e "$ENV_FILE" ] || fail "$ENV_FILE exists without an installation receipt; refusing to guess whether its administrator token is safe to replace" + require_runtime_dependencies + + if [ "$MODE" = source ]; then + [ "${WEBCODEX_RELEASE_BOOTSTRAP:-false}" != true ] || fail "release bootstrap assets do not support --build-from-source" + [ -f "$BUILD_OVERLAY" ] || fail "$BUILD_OVERLAY is required for --build-from-source" + [ -f Dockerfile ] || fail "Dockerfile is required for --build-from-source" + fi + + if [ "$MODE" = source ]; then + WEBCODEX_TOKEN=$ZERO_TOKEN WEBCODEX_PUBLIC_URL="$PUBLIC_URL" \ + compose_full config >/dev/null || fail "source Compose configuration is invalid" + else + if [ -z "$SERVER_IMAGE" ]; then + SERVER_IMAGE=$(WEBCODEX_TOKEN=$ZERO_TOKEN WEBCODEX_PUBLIC_URL="$PUBLIC_URL" compose_base config --images) + fi + validate_server_image "$SERVER_IMAGE" || { echo "invalid WEBCODEX_SERVER_IMAGE: expected a Docker image reference" >&2 exit 2 - ;; + } + WEBCODEX_TOKEN=$ZERO_TOKEN WEBCODEX_PUBLIC_URL="$PUBLIC_URL" WEBCODEX_SERVER_IMAGE="$SERVER_IMAGE" \ + compose_base config >/dev/null || fail "Compose configuration is invalid" + fi + + if [ "$MODE" = image ]; then + existing=$(WEBCODEX_TOKEN=$ZERO_TOKEN WEBCODEX_PUBLIC_URL="$PUBLIC_URL" WEBCODEX_SERVER_IMAGE="$SERVER_IMAGE" \ + compose_full ps -aq webcodex 2>/dev/null || true) + else + existing=$(WEBCODEX_TOKEN=$ZERO_TOKEN WEBCODEX_PUBLIC_URL="$PUBLIC_URL" \ + compose_full ps -aq webcodex 2>/dev/null || true) + fi + [ -z "$existing" ] || fail "an existing WebCodex Compose container was found without an installation receipt; refusing to adopt it implicitly" + port_preflight + + if [ "$MODE" = image ]; then + WEBCODEX_TOKEN=$ZERO_TOKEN WEBCODEX_PUBLIC_URL="$PUBLIC_URL" WEBCODEX_SERVER_IMAGE="$SERVER_IMAGE" \ + compose_base pull webcodex || { + echo "could not pull the published WebCodex Server image: $SERVER_IMAGE" >&2 + echo "If the official image is not published/public yet, retry with --build-from-source." >&2 + exit 1 + } + fi + + COMPOSE_DIGEST=$(sha256_file "$COMPOSE_FILE") || fail "could not hash compose file" + if [ "$MODE" = source ]; then + OVERLAY_DIGEST=$(sha256_file "$BUILD_OVERLAY") || fail "could not hash source build overlay" + else + OVERLAY_DIGEST=- + fi + ENV_DIGEST=- + write_receipt AssetsPrepared - +} + +generate_token() { + if command -v openssl >/dev/null 2>&1; then + TOKEN=$(openssl rand -hex 32) + else + TOKEN=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') + fi + case "$TOKEN" in + ????????????????????????????????????????????????????????????????) ;; + *) fail "failed to generate a 32-byte administrator token" ;; esac - if ! WEBCODEX_TOKEN=0000000000000000000000000000000000000000000000000000000000000000 \ - WEBCODEX_PUBLIC_URL="$PUBLIC_URL" \ - WEBCODEX_SERVER_IMAGE="$SERVER_IMAGE" \ - compose pull webcodex; then - echo "could not pull the published WebCodex Server image: $SERVER_IMAGE" >&2 - echo "If the official image is not published/public yet, retry with --build-from-source." >&2 - exit 1 - fi -fi - -if command -v openssl >/dev/null 2>&1; then - TOKEN=$(openssl rand -hex 32) -else - TOKEN=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') -fi - -umask 077 -cat > .env <> .env -fi -chmod 600 .env - -if [ "$BUILD_FROM_SOURCE" = true ]; then + case "$TOKEN" in + *[!0-9a-f]*) fail "administrator token generator returned non-hex data" ;; + esac +} + +commit_secret_env() { + generate_token + tmp="$ENV_FILE.$$.tmp" + TEMP_FILES=$tmp + umask 077 + { + printf 'WEBCODEX_PUBLIC_URL=%s\n' "$PUBLIC_URL" + printf 'WEBCODEX_TOKEN=%s\n' "$TOKEN" + printf 'WEBCODEX_HOST_IP=%s\n' "$HOST_IP" + printf 'WEBCODEX_HOST_PORT=%s\n' "$HOST_PORT" + printf 'RUST_LOG=info\n' + printf 'WEBCODEX_MCP_MODEL_SURFACE=local-coding-v1\n' + printf 'COMPOSE_FILE=%s\n' "$COMPOSE_FILE" + if [ "$MODE" = image ]; then + printf 'WEBCODEX_SERVER_IMAGE=%s\n' "$SERVER_IMAGE" + fi + } > "$tmp" + atomic_commit "$ENV_FILE" "$tmp" || return 1 + ENV_DIGEST=$(sha256_file "$ENV_FILE") || fail "could not fingerprint committed $ENV_FILE" + write_receipt SecretCommitted "$ENV_DIGEST" +} + +prepare_source_build_identity() { WEBCODEX_GIT_COMMIT= WEBCODEX_GIT_DIRTY= if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then @@ -120,25 +554,79 @@ if [ "$BUILD_FROM_SOURCE" = true ]; then fi WEBCODEX_BUILT_AT=$(date +%s) export WEBCODEX_GIT_COMMIT WEBCODEX_GIT_DIRTY WEBCODEX_BUILT_AT - if [ ! -f compose.build.yaml ]; then - echo "compose.build.yaml is required for --build-from-source" >&2 - exit 1 - fi - docker compose -f "$COMPOSE_FILE" -f compose.build.yaml up -d --build - DEPLOYMENT_SOURCE="local source build" -else - # The image was pulled before .env was created, so startup cannot strand a - # fresh bootstrap merely because the registry becomes briefly unavailable. - compose up -d --no-build --pull never - DEPLOYMENT_SOURCE="$SERVER_IMAGE" -fi - -cat </dev/null || true +} + +start_container_if_needed() { + existing=$(container_id) + if [ -n "$existing" ]; then + write_receipt ContainerStarted "$ENV_DIGEST" + return 0 + fi + + port_preflight + if [ "$MODE" = source ]; then + prepare_source_build_identity + compose_full up -d --build || return 1 + else + # Fresh preflight already pulled the exact image. After SecretCommitted, + # retries must not acquire a new registry dependency merely to recover + # from a port/daemon/startup failure. + compose_base up -d --no-build --pull never || return 1 + fi + existing=$(container_id) + [ -n "$existing" ] || fail "docker compose up returned success but no webcodex container exists" + write_receipt ContainerStarted "$ENV_DIGEST" +} + +wait_for_server_health() { + cid=$(container_id) + [ -n "$cid" ] || fail "WebCodex container is missing; run '$0 rollback' and retry resume" + waited=0 + while [ "$waited" -le "$HEALTH_WAIT_SECS" ]; do + health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid" 2>/dev/null || true) + case "$health" in + healthy) + compose_full exec -T webcodex curl -fsS http://127.0.0.1:8080/openapi.json >/dev/null \ + || fail "WebCodex healthcheck is healthy but /openapi.json verification failed" + write_receipt ServerHealthy "$ENV_DIGEST" + return 0 + ;; + unhealthy|exited|dead) + fail "WebCodex container became $health before the Server was ready; fix the cause and run '$0 resume'" + ;; + esac + sleep 2 + waited=$((waited + 2)) + done + fail "timed out waiting for WebCodex Server health; run '$0 status' and '$0 resume' after fixing the cause" +} + +create_pairing_code() { + PAIRING_OUTPUT=$(compose_full exec -T webcodex sh -lc \ + 'webcodex pairing create --server-url "$WEBCODEX_PUBLIC_URL" --username admin --ttl-secs 600') \ + || fail "Server is healthy but pairing-code creation failed; run '$0 resume' to retry only this final stage" + printf '%s\n' "$PAIRING_OUTPUT" + write_receipt PairingReady "$ENV_DIGEST" +} + +print_success() { + if [ "$MODE" = source ]; then + DEPLOYMENT_SOURCE="local source build" + else + DEPLOYMENT_SOURCE=$SERVER_IMAGE + fi + cat < --allowed-root "\$HOME/git" webcodex agent install --scope user --config -Keep .env private. It contains the bootstrap administrator token. Do not copy +Keep $ENV_FILE private. It contains the bootstrap administrator token. Do not copy that token to a repository machine or pass it to webcodex connect; connect is the separate hosted shared-key path. EOF_DONE +} + +resume_install() { + # Receipt/file integrity is fail-closed and checked before invoking Docker so + # a tampered env or deployment asset cannot cause any runtime side effect. + verify_files_against_receipt + require_runtime_dependencies + + if [ "$PHASE" = AssetsPrepared ]; then + reconcile_secret_commit_if_needed + fi + + if [ "$PHASE" = AssetsPrepared ]; then + commit_secret_env || fail "failed to durably commit $ENV_FILE; no partial $ENV_FILE was installed" + fi + + if [ "$PHASE" = SecretCommitted ]; then + start_container_if_needed || fail "docker compose up failed; the administrator token and receipt were preserved for '$0 resume'" + fi + + if [ "$PHASE" = ContainerStarted ]; then + wait_for_server_health + fi + + if [ "$PHASE" = ServerHealthy ]; then + create_pairing_code + fi + + [ "$PHASE" = PairingReady ] || fail "bootstrap stopped at unexpected phase: $PHASE" + print_success +} + +show_status() { + if [ ! -e "$RECEIPT_FILE" ]; then + if [ -e "$ENV_FILE" ]; then + fail "$ENV_FILE exists without an installation receipt; this deployment predates recoverable bootstrap state" + fi + echo "WebCodex bootstrap status: not started" + return 0 + fi + load_receipt + verify_files_against_receipt + if [ "$PHASE" = AssetsPrepared ] && [ -e "$ENV_FILE" ]; then + validate_committed_env + env_status="yes (atomic commit complete; receipt reconciliation pending)" + elif [ "$ENV_DIGEST" = - ]; then + env_status=no + else + env_status=yes + fi + cat </dev/null || true) + if [ -n "$existing" ]; then + compose_full down || fail "rollback could not stop/remove the Compose container; receipt and administrator token were preserved" + fi + # Preserve the administrator token and named volume. Regenerating the token + # after the Server may have initialized durable state can create a real lockout. + write_receipt SecretCommitted "$ENV_DIGEST" + cat < str: required_bootstrap_markers = ( "COMPOSE_FILE", "docker compose -f", - "compose config --images", + "compose_base config --images", ) if any(marker not in bootstrap for marker in required_bootstrap_markers): raise ValueError("bootstrap script does not support standalone compose resolution") @@ -58,27 +58,10 @@ def render_bootstrap(*, compose: str, bootstrap: str, digest: str) -> str: prelude = f"""#!/bin/sh # Generated by the reviewed WebCodex release-image workflow. The embedded # Compose definition is pinned to one immutable multi-arch Server image digest. +# Argument validation and recoverable installation state live only in the +# canonical bootstrap body below; this prelude only materializes the pinned asset. set -eu -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " >&2 - exit 2 -fi -release_public_url=${{1%/}} -case "$release_public_url" in - https://*) ;; - *) echo "public URL must start with https://" >&2; exit 2 ;; -esac -release_origin=${{release_public_url#https://}} -case "$release_origin" in - ""|*/*) echo "public URL must be an HTTPS origin without a path" >&2; exit 2 ;; -esac - -if [ -e .env ]; then - echo ".env already exists; refusing to modify this deployment" >&2 - exit 1 -fi - compose_target={MATERIALIZED_COMPOSE} compose_tmp=".{MATERIALIZED_COMPOSE}.$$.tmp" cleanup_release_compose() {{ rm -f "$compose_tmp"; }} @@ -98,7 +81,8 @@ def render_bootstrap(*, compose: str, bootstrap: str, digest: str) -> str: fi trap - EXIT HUP INT TERM COMPOSE_FILE="$compose_target" -export COMPOSE_FILE +WEBCODEX_RELEASE_BOOTSTRAP=true +export COMPOSE_FILE WEBCODEX_RELEASE_BOOTSTRAP """ return prelude + body diff --git a/scripts/tests/test_release_publication.py b/scripts/tests/test_release_publication.py index fdf7ee36..5193407a 100644 --- a/scripts/tests/test_release_publication.py +++ b/scripts/tests/test_release_publication.py @@ -571,9 +571,9 @@ def test_compose_defaults_to_published_image_with_explicit_source_override(self) self.assertIn("build:\n", source) self.assertIn("--build-from-source", bootstrap) self.assertIn("COMPOSE_FILE=${COMPOSE_FILE:-compose.yaml}", bootstrap) - self.assertIn("compose config --images", bootstrap) - self.assertIn("compose pull webcodex", bootstrap) - self.assertIn("compose.build.yaml up -d --build", bootstrap) + self.assertIn("compose_base config --images", bootstrap) + self.assertIn("compose_base pull webcodex", bootstrap) + self.assertIn("compose_full up -d --build", bootstrap) if __name__ == "__main__": diff --git a/scripts/tests/test_server_docker_bootstrap.py b/scripts/tests/test_server_docker_bootstrap.py index 980eb73b..e27f160c 100644 --- a/scripts/tests/test_server_docker_bootstrap.py +++ b/scripts/tests/test_server_docker_bootstrap.py @@ -13,12 +13,18 @@ ROOT = Path(__file__).resolve().parents[2] BOOTSTRAP = ROOT / "deploy" / "docker" / "bootstrap.sh" COMPOSE = ROOT / "compose.yaml" +BUILD_COMPOSE = ROOT / "compose.build.yaml" +DOCKERFILE = ROOT / "Dockerfile" DIGEST = "sha256:" + "a" * 64 PINNED_IMAGE = f"{assets.SERVER_IMAGE}@{DIGEST}" +PUBLIC_URL = "https://webcodex.example.com" +RECEIPT = ".webcodex-bootstrap.receipt" +TOKEN = "b" * 64 +SECOND_TOKEN = "c" * 64 class DeploymentAssetTests(unittest.TestCase): - def test_release_bootstrap_embeds_digest_pinned_compose(self) -> None: + def test_release_bootstrap_embeds_digest_pinned_compose_without_duplicate_validation(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) output = root / "out" @@ -29,15 +35,20 @@ def test_release_bootstrap_embeds_digest_pinned_compose(self) -> None: digest=DIGEST, ) self.assertEqual(set(result), {assets.BOOTSTRAP_ASSET}) - generated = (output / assets.BOOTSTRAP_ASSET).read_text(encoding="utf-8") + generated_path = output / assets.BOOTSTRAP_ASSET + generated = generated_path.read_text(encoding="utf-8") self.assertIn(PINNED_IMAGE, generated) self.assertNotIn(f"{assets.SERVER_IMAGE}:latest", generated) self.assertIn(f"compose_target={assets.MATERIALIZED_COMPOSE}", generated) self.assertIn("cmp -s", generated) + self.assertIn("WEBCODEX_RELEASE_BOOTSTRAP=true", generated) + self.assertNotIn("release_public_url=", generated) + self.assertEqual(generated.count("validate_public_url()"), 1) self.assertEqual( - assets.sha256_bytes((output / assets.BOOTSTRAP_ASSET).read_bytes()), + assets.sha256_bytes(generated_path.read_bytes()), result[assets.BOOTSTRAP_ASSET], ) + subprocess.run(["sh", "-n", generated_path], check=True) def test_asset_preparation_rejects_noncanonical_digest(self) -> None: with tempfile.TemporaryDirectory() as temp: @@ -51,53 +62,128 @@ def test_asset_preparation_rejects_noncanonical_digest(self) -> None: class BootstrapTests(unittest.TestCase): - def _workspace(self, *, pull_ok: bool = True) -> tuple[Path, dict[str, str]]: - root = Path(tempfile.mkdtemp(prefix="webcodex-bootstrap-test-")) - self.addCleanup(shutil.rmtree, root, True) - generated = root / "generated" - assets.prepare_assets( - compose_path=COMPOSE, - bootstrap_path=BOOTSTRAP, - output_dir=generated, - digest=DIGEST, - ) - shutil.copy2(generated / assets.BOOTSTRAP_ASSET, root / assets.BOOTSTRAP_ASSET) - + def _fake_tools(self, root: Path) -> dict[str, str]: bin_dir = root / "bin" bin_dir.mkdir() log = root / "docker.log" - fake = bin_dir / "docker" - fake.write_text( + state = root / "container.state" + + docker = bin_dir / "docker" + docker.write_text( "#!/bin/sh\n" "set -eu\n" 'printf "%s\\n" "$*" >> "$FAKE_DOCKER_LOG"\n' + 'args="$*"\n' 'if [ "$1" = compose ] && [ "$2" = version ]; then exit 0; fi\n' - 'if [ "$1" = compose ] && [ "$4" = config ] && [ "$5" = --images ]; then\n' - f" printf '%s\\n' '{PINNED_IMAGE}'\n" - " exit 0\n" - "fi\n" - 'if [ "$1" = compose ] && [ "$4" = pull ]; then\n' - f" exit {0 if pull_ok else 23}\n" - "fi\n" - 'if [ "$1" = compose ] && [ "$4" = up ]; then exit 0; fi\n' + 'if [ "$1" = inspect ]; then printf "%s\\n" "${FAKE_HEALTH_STATUS:-healthy}"; exit 0; fi\n' + 'case "$args" in\n' + ' *" config --images"*) printf "%s\\n" "$FAKE_PINNED_IMAGE"; exit 0 ;;\n' + ' *" config"*) exit 0 ;;\n' + ' *" pull webcodex"*) exit "${FAKE_PULL_EXIT:-0}" ;;\n' + ' *" ps -aq webcodex"*|*" ps -q webcodex"*)\n' + ' if [ -f "$FAKE_CONTAINER_STATE" ]; then printf "fake-container\\n"; fi; exit 0 ;;\n' + ' *" up "*)\n' + ' if [ "${FAKE_UP_LEAVES_CONTAINER:-0}" = 1 ]; then : > "$FAKE_CONTAINER_STATE"; fi\n' + ' if [ "${FAKE_UP_EXIT:-0}" != 0 ]; then exit "$FAKE_UP_EXIT"; fi\n' + ' : > "$FAKE_CONTAINER_STATE"; exit 0 ;;\n' + ' *" exec -T webcodex curl "*) exit "${FAKE_OPENAPI_EXIT:-0}" ;;\n' + ' *" exec -T webcodex sh -lc "*)\n' + ' if [ "${FAKE_PAIRING_EXIT:-0}" != 0 ]; then exit "$FAKE_PAIRING_EXIT"; fi\n' + ' printf "wc_pair_test_123\\n"; exit 0 ;;\n' + ' *" down"*) rm -f "$FAKE_CONTAINER_STATE"; exit "${FAKE_DOWN_EXIT:-0}" ;;\n' + 'esac\n' "exit 0\n", encoding="utf-8", ) - fake.chmod(fake.stat().st_mode | stat.S_IXUSR) + docker.chmod(docker.stat().st_mode | stat.S_IXUSR) + + ss = bin_dir / "ss" + ss.write_text( + "#!/bin/sh\n" + 'if [ "${FAKE_PORT_BUSY:-0}" = 1 ]; then printf "LISTEN fake:8080\\n"; fi\n', + encoding="utf-8", + ) + ss.chmod(ss.stat().st_mode | stat.S_IXUSR) + + sync = bin_dir / "sync" + sync.write_text( + "#!/bin/sh\n" + 'case "${FAKE_SYNC_FAIL_FOR:-}" in\n' + ' env) case "$*" in *".env."*) exit 31 ;; esac ;;\n' + ' receipt) case "$*" in *".webcodex-bootstrap.receipt."*) exit 32 ;; esac ;;\n' + ' receipt_after_env)\n' + ' case "$*" in *".webcodex-bootstrap.receipt."*) [ -f .env ] && exit 33 ;; esac ;;\n' + 'esac\n' + "exit 0\n", + encoding="utf-8", + ) + sync.chmod(sync.stat().st_mode | stat.S_IXUSR) + + token_count = root / "token.count" + openssl = bin_dir / "openssl" + openssl.write_text( + "#!/bin/sh\n" + 'count=0\n' + 'if [ -f "$FAKE_TOKEN_COUNT" ]; then count=$(cat "$FAKE_TOKEN_COUNT"); fi\n' + 'printf "%s\\n" "$((count + 1))" > "$FAKE_TOKEN_COUNT"\n' + f"if [ \"$count\" -eq 0 ]; then printf '%s\\n' '{TOKEN}'; else printf '%s\\n' '{SECOND_TOKEN}'; fi\n", + encoding="utf-8", + ) + openssl.chmod(openssl.stat().st_mode | stat.S_IXUSR) + env = os.environ.copy() env.update( { "PATH": str(bin_dir) + os.pathsep + env.get("PATH", ""), "FAKE_DOCKER_LOG": str(log), + "FAKE_CONTAINER_STATE": str(state), + "FAKE_PINNED_IMAGE": PINNED_IMAGE, + "FAKE_TOKEN_COUNT": str(token_count), + "WEBCODEX_BOOTSTRAP_HEALTH_WAIT_SECS": "2", } ) env.pop("COMPOSE_FILE", None) env.pop("WEBCODEX_SERVER_IMAGE", None) - return root, env + env.pop("WEBCODEX_RELEASE_BOOTSTRAP", None) + return env - def _run(self, root: Path, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + def _generated_workspace(self) -> tuple[Path, dict[str, str], str]: + root = Path(tempfile.mkdtemp(prefix="webcodex-bootstrap-test-")) + self.addCleanup(shutil.rmtree, root, True) + generated = root / "generated" + assets.prepare_assets( + compose_path=COMPOSE, + bootstrap_path=BOOTSTRAP, + output_dir=generated, + digest=DIGEST, + ) + shutil.copy2(generated / assets.BOOTSTRAP_ASSET, root / assets.BOOTSTRAP_ASSET) + return root, self._fake_tools(root), assets.BOOTSTRAP_ASSET + + def _source_workspace( + self, *, include_overlay: bool = True, include_dockerfile: bool = True + ) -> tuple[Path, dict[str, str], str]: + root = Path(tempfile.mkdtemp(prefix="webcodex-bootstrap-source-test-")) + self.addCleanup(shutil.rmtree, root, True) + shutil.copy2(BOOTSTRAP, root / "bootstrap.sh") + shutil.copy2(COMPOSE, root / "compose.yaml") + if include_overlay: + shutil.copy2(BUILD_COMPOSE, root / "compose.build.yaml") + if include_dockerfile: + shutil.copy2(DOCKERFILE, root / "Dockerfile") + return root, self._fake_tools(root), "bootstrap.sh" + + def _run( + self, + root: Path, + env: dict[str, str], + script: str, + *args: str, + ) -> subprocess.CompletedProcess[str]: + if not args: + args = (PUBLIC_URL,) return subprocess.run( - ["sh", assets.BOOTSTRAP_ASSET, "https://webcodex.example.com"], + ["sh", script, *args], cwd=root, env=env, text=True, @@ -106,10 +192,19 @@ def _run(self, root: Path, env: dict[str, str]) -> subprocess.CompletedProcess[s check=False, ) - def test_clone_free_bootstrap_materializes_and_uses_pinned_compose(self) -> None: - root, env = self._workspace() - result = self._run(root, env) + def _receipt(self, root: Path) -> dict[str, str]: + lines = (root / RECEIPT).read_text(encoding="utf-8").splitlines() + return dict(line.split("=", 1) for line in lines) + + def _docker_log(self, root: Path) -> str: + path = root / "docker.log" + return path.read_text(encoding="utf-8") if path.exists() else "" + + def test_clone_free_bootstrap_commits_secret_waits_for_health_and_creates_pairing(self) -> None: + root, env, script = self._generated_workspace() + result = self._run(root, env, script) self.assertEqual(result.returncode, 0, result.stderr) + compose = root / assets.MATERIALIZED_COMPOSE self.assertTrue(compose.is_file()) self.assertIn(PINNED_IMAGE, compose.read_text(encoding="utf-8")) @@ -119,46 +214,289 @@ def test_clone_free_bootstrap_materializes_and_uses_pinned_compose(self) -> None text = env_file.read_text(encoding="utf-8") self.assertIn(f"COMPOSE_FILE={assets.MATERIALIZED_COMPOSE}\n", text) self.assertIn(f"WEBCODEX_SERVER_IMAGE={PINNED_IMAGE}\n", text) + + receipt = self._receipt(root) + self.assertEqual(receipt["phase"], "PairingReady") + self.assertEqual(receipt["public_url"], PUBLIC_URL) + self.assertEqual(receipt["compose_file"], assets.MATERIALIZED_COMPOSE) + self.assertNotEqual(receipt["env_sha256"], "-") + self.assertEqual(stat.S_IMODE((root / RECEIPT).stat().st_mode), 0o600) + + self.assertIn("wc_pair_test_123", result.stdout) + self.assertIn("WebCodex server is healthy", result.stdout) + self.assertNotIn("server container started", result.stdout.lower()) self.assertIn("webcodex login", result.stdout) self.assertIn("webcodex agent install --scope user", result.stdout) self.assertIn("Do not copy", result.stdout) self.assertIn("webcodex connect", result.stdout) - calls = (root / "docker.log").read_text(encoding="utf-8") + + calls = self._docker_log(root) self.assertIn(f"compose -f {assets.MATERIALIZED_COMPOSE} config --images", calls) self.assertIn(f"compose -f {assets.MATERIALIZED_COMPOSE} pull webcodex", calls) - self.assertIn(f"compose -f {assets.MATERIALIZED_COMPOSE} up -d --no-build --pull never", calls) + self.assertIn( + f"compose -f {assets.MATERIALIZED_COMPOSE} up -d --no-build --pull never", calls + ) + self.assertIn("inspect --format", calls) + self.assertIn("openapi.json", calls) + self.assertIn("pairing create", calls) - def test_pull_failure_keeps_retryable_compose_but_no_secret_env(self) -> None: - root, env = self._workspace(pull_ok=False) - result = self._run(root, env) + second = self._run(root, env, script) + self.assertNotEqual(second.returncode, 0) + self.assertIn("installation receipt already exists", second.stderr) + + def test_pull_failure_happens_before_receipt_or_secret(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_PULL_EXIT"] = "23" + result = self._run(root, env, script) self.assertNotEqual(result.returncode, 0) self.assertTrue((root / assets.MATERIALIZED_COMPOSE).is_file()) self.assertFalse((root / ".env").exists()) + self.assertFalse((root / RECEIPT).exists()) - def test_invalid_url_does_not_materialize_compose(self) -> None: - root, env = self._workspace() - result = subprocess.run( - ["sh", assets.BOOTSTRAP_ASSET, "http://not-https.example.com"], - cwd=root, - env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) + def test_source_overlay_and_dockerfile_are_preflighted_before_secret(self) -> None: + root, env, script = self._source_workspace(include_overlay=False) + result = self._run(root, env, script, PUBLIC_URL, "--build-from-source") self.assertNotEqual(result.returncode, 0) - self.assertFalse((root / assets.MATERIALIZED_COMPOSE).exists()) + self.assertIn("compose.build.yaml is required", result.stderr) self.assertFalse((root / ".env").exists()) - self.assertFalse((root / "docker.log").exists()) + self.assertFalse((root / RECEIPT).exists()) + + root2, env2, script2 = self._source_workspace(include_dockerfile=False) + result2 = self._run(root2, env2, script2, PUBLIC_URL, "--build-from-source") + self.assertNotEqual(result2.returncode, 0) + self.assertIn("Dockerfile is required", result2.stderr) + self.assertFalse((root2 / ".env").exists()) + self.assertFalse((root2 / RECEIPT).exists()) + + def test_source_build_uses_same_transaction_state_machine(self) -> None: + root, env, script = self._source_workspace() + result = self._run(root, env, script, PUBLIC_URL, "--build-from-source") + self.assertEqual(result.returncode, 0, result.stderr) + receipt = self._receipt(root) + self.assertEqual(receipt["mode"], "source") + self.assertEqual(receipt["phase"], "PairingReady") + self.assertNotEqual(receipt["overlay_sha256"], "-") + self.assertNotIn("WEBCODEX_SERVER_IMAGE=", (root / ".env").read_text(encoding="utf-8")) + self.assertIn("-f compose.build.yaml up -d --build", self._docker_log(root)) + + def test_up_failure_preserves_secret_and_resume_reuses_it(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_UP_EXIT"] = "42" + first = self._run(root, env, script) + self.assertNotEqual(first.returncode, 0) + self.assertEqual(self._receipt(root)["phase"], "SecretCommitted") + env_bytes = (root / ".env").read_bytes() + + env["FAKE_UP_EXIT"] = "0" + resumed = self._run(root, env, script, "resume") + self.assertEqual(resumed.returncode, 0, resumed.stderr) + self.assertEqual(self._receipt(root)["phase"], "PairingReady") + self.assertEqual((root / ".env").read_bytes(), env_bytes) + self.assertGreaterEqual(self._docker_log(root).count(" up -d "), 2) + + def test_up_uncertain_container_is_reconciled_without_regenerating_secret(self) -> None: + root, env, script = self._generated_workspace() + env.update({"FAKE_UP_EXIT": "44", "FAKE_UP_LEAVES_CONTAINER": "1"}) + first = self._run(root, env, script) + self.assertNotEqual(first.returncode, 0) + self.assertEqual(self._receipt(root)["phase"], "SecretCommitted") + env_bytes = (root / ".env").read_bytes() + up_count = self._docker_log(root).count(" up -d ") + + env["FAKE_UP_EXIT"] = "0" + resumed = self._run(root, env, script, "resume") + self.assertEqual(resumed.returncode, 0, resumed.stderr) + self.assertEqual((root / ".env").read_bytes(), env_bytes) + self.assertEqual(self._docker_log(root).count(" up -d "), up_count) + + def test_health_failure_stops_before_pairing_and_resume_finishes(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_HEALTH_STATUS"] = "unhealthy" + first = self._run(root, env, script) + self.assertNotEqual(first.returncode, 0) + self.assertEqual(self._receipt(root)["phase"], "ContainerStarted") + self.assertNotIn("pairing create", self._docker_log(root)) + env_bytes = (root / ".env").read_bytes() + + env["FAKE_HEALTH_STATUS"] = "healthy" + resumed = self._run(root, env, script, "resume") + self.assertEqual(resumed.returncode, 0, resumed.stderr) + self.assertEqual(self._receipt(root)["phase"], "PairingReady") + self.assertEqual((root / ".env").read_bytes(), env_bytes) + self.assertIn("pairing create", self._docker_log(root)) + + def test_pairing_failure_is_retryable_from_server_healthy(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_PAIRING_EXIT"] = "47" + first = self._run(root, env, script) + self.assertNotEqual(first.returncode, 0) + self.assertEqual(self._receipt(root)["phase"], "ServerHealthy") + up_count = self._docker_log(root).count(" up -d ") + + env["FAKE_PAIRING_EXIT"] = "0" + resumed = self._run(root, env, script, "resume") + self.assertEqual(resumed.returncode, 0, resumed.stderr) + self.assertEqual(self._receipt(root)["phase"], "PairingReady") + self.assertEqual(self._docker_log(root).count(" up -d "), up_count) - def test_existing_different_compose_fails_before_docker_or_secret_creation(self) -> None: - root, env = self._workspace() + def test_atomic_env_sync_failure_leaves_no_partial_env_and_resume_recovers(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_SYNC_FAIL_FOR"] = "env" + first = self._run(root, env, script) + self.assertNotEqual(first.returncode, 0) + self.assertEqual(self._receipt(root)["phase"], "AssetsPrepared") + self.assertFalse((root / ".env").exists()) + self.assertEqual(list(root.glob(".env.*.tmp")), []) + + env.pop("FAKE_SYNC_FAIL_FOR") + resumed = self._run(root, env, script, "resume") + self.assertEqual(resumed.returncode, 0, resumed.stderr) + self.assertEqual(self._receipt(root)["phase"], "PairingReady") + + def test_atomic_receipt_sync_failure_never_creates_secret(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_SYNC_FAIL_FOR"] = "receipt" + result = self._run(root, env, script) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((root / RECEIPT).exists()) + self.assertFalse((root / ".env").exists()) + self.assertEqual(list(root.glob(".webcodex-bootstrap.receipt.*.tmp")), []) + + def test_receipt_failure_after_env_commit_reconciles_without_regenerating_token(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_SYNC_FAIL_FOR"] = "receipt_after_env" + first = self._run(root, env, script) + self.assertNotEqual(first.returncode, 0) + self.assertEqual(self._receipt(root)["phase"], "AssetsPrepared") + env_bytes = (root / ".env").read_bytes() + self.assertIn(TOKEN.encode(), env_bytes) + + status = self._run(root, env, script, "status") + self.assertEqual(status.returncode, 0, status.stderr) + self.assertNotIn(TOKEN, status.stdout) + self.assertIn("receipt reconciliation pending", status.stdout) + self.assertNotIn(SECOND_TOKEN, status.stdout) + + env.pop("FAKE_SYNC_FAIL_FOR") + resumed = self._run(root, env, script, "resume") + self.assertEqual(resumed.returncode, 0, resumed.stderr) + self.assertEqual(self._receipt(root)["phase"], "PairingReady") + self.assertEqual((root / ".env").read_bytes(), env_bytes) + self.assertEqual((root / "token.count").read_text(encoding="utf-8"), "1\n") + + def test_status_reports_phase_without_disclosing_admin_token(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_UP_EXIT"] = "49" + self.assertNotEqual(self._run(root, env, script).returncode, 0) + status = self._run(root, env, script, "status") + self.assertEqual(status.returncode, 0, status.stderr) + self.assertIn("phase: SecretCommitted", status.stdout) + self.assertIn("env present: yes", status.stdout) + self.assertNotIn(TOKEN, status.stdout) + self.assertNotIn(TOKEN, status.stderr) + + def test_rollback_preserves_secret_and_volume_checkpoint_then_resume_works(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_HEALTH_STATUS"] = "unhealthy" + self.assertNotEqual(self._run(root, env, script).returncode, 0) + self.assertEqual(self._receipt(root)["phase"], "ContainerStarted") + env_bytes = (root / ".env").read_bytes() + + rolled = self._run(root, env, script, "rollback") + self.assertEqual(rolled.returncode, 0, rolled.stderr) + self.assertEqual(self._receipt(root)["phase"], "SecretCommitted") + self.assertEqual((root / ".env").read_bytes(), env_bytes) + self.assertIn(" down", self._docker_log(root)) + self.assertNotIn(" -v", self._docker_log(root)) + + env["FAKE_HEALTH_STATUS"] = "healthy" + resumed = self._run(root, env, script, "resume") + self.assertEqual(resumed.returncode, 0, resumed.stderr) + self.assertEqual(self._receipt(root)["phase"], "PairingReady") + + def test_env_fingerprint_drift_blocks_resume_before_docker_effect(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_UP_EXIT"] = "50" + self.assertNotEqual(self._run(root, env, script).returncode, 0) + before = self._docker_log(root) + with (root / ".env").open("a", encoding="utf-8") as handle: + handle.write("EXTRA=unexpected\n") + + env["FAKE_UP_EXIT"] = "0" + resumed = self._run(root, env, script, "resume") + self.assertNotEqual(resumed.returncode, 0) + self.assertIn("fingerprint does not match", resumed.stderr) + self.assertEqual(self._docker_log(root), before) + + def test_busy_port_fails_before_pull_receipt_or_secret(self) -> None: + root, env, script = self._generated_workspace() + env["FAKE_PORT_BUSY"] = "1" + result = self._run(root, env, script) + self.assertNotEqual(result.returncode, 0) + self.assertIn("already listening", result.stderr) + self.assertFalse((root / RECEIPT).exists()) + self.assertFalse((root / ".env").exists()) + self.assertNotIn("pull webcodex", self._docker_log(root)) + + def test_public_url_accepts_only_strict_https_origins(self) -> None: + invalid = [ + "http://example.com", + "https://user@example.com", + "https://example.com/path", + "https://example.com?query", + "https://example.com#fragment", + "https://example.com\nRUST_LOG=trace", + "https://999.1.1.1", + "https://[::::]", + "https://example.com:0", + "https://example.com:65536", + "https://-bad.example.com", + ] + for value in invalid: + with self.subTest(value=value): + root, env, script = self._generated_workspace() + result = self._run(root, env, script, value) + self.assertNotEqual(result.returncode, 0, (value, result.stdout, result.stderr)) + self.assertFalse((root / ".env").exists()) + self.assertFalse((root / RECEIPT).exists()) + self.assertEqual(self._docker_log(root), "") + # Release assets may materialize their deterministic pinned Compose + # before the canonical body validates arguments; no secret/effect does. + self.assertTrue((root / assets.MATERIALIZED_COMPOSE).exists()) + + valid = [ + "https://example.com", + "https://example.com:8443", + "https://127.0.0.1:8443", + "https://[::1]:8443", + "https://[2001:db8::1]", + ] + for value in valid: + with self.subTest(value=value): + root, env, script = self._generated_workspace() + result = self._run(root, env, script, value) + self.assertEqual(result.returncode, 0, (value, result.stdout, result.stderr)) + self.assertEqual(self._receipt(root)["phase"], "PairingReady") + + def test_existing_different_generated_compose_fails_before_docker_or_secret(self) -> None: + root, env, script = self._generated_workspace() (root / assets.MATERIALIZED_COMPOSE).write_text("different\n", encoding="utf-8") - result = self._run(root, env) + result = self._run(root, env, script) self.assertNotEqual(result.returncode, 0) self.assertIn("different content", result.stderr) self.assertFalse((root / ".env").exists()) - self.assertFalse((root / "docker.log").exists()) + self.assertFalse((root / RECEIPT).exists()) + self.assertEqual(self._docker_log(root), "") + + def test_unmanaged_env_is_never_overwritten_or_rolled_back(self) -> None: + root, env, script = self._generated_workspace() + (root / ".env").write_text("WEBCODEX_TOKEN=keep-me\n", encoding="utf-8") + install = self._run(root, env, script) + self.assertNotEqual(install.returncode, 0) + self.assertIn("without an installation receipt", install.stderr) + rollback = self._run(root, env, script, "rollback") + self.assertNotEqual(rollback.returncode, 0) + self.assertEqual((root / ".env").read_text(encoding="utf-8"), "WEBCODEX_TOKEN=keep-me\n") if __name__ == "__main__":