diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index f72135a8..3479a5e0 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -79,6 +79,9 @@ jobs: cache-dependency-path: web/package-lock.json - run: npm install + - name: Exercise managed local installer migration orchestration + working-directory: . + run: bash scripts/test-install-managed-migrations.sh - name: Install redis-cli for migration proof working-directory: . run: | @@ -109,6 +112,8 @@ jobs: CREATE DATABASE forge_migration_0027_upgrade_test OWNER forge_migration_test; CREATE DATABASE forge_migration_0028_upgrade_test OWNER forge_migration_test; CREATE DATABASE forge_s5_handoff_failure_test OWNER forge_migration_test; + CREATE DATABASE forge_installer_managed_migration_test OWNER forge_migration_test; + CREATE DATABASE forge_installer_managed_s5_failure_test OWNER forge_migration_test; CREATE DATABASE forge_s4_ci_test OWNER forge_migration_test; SQL - name: Prove populated migration 0025 to 0026 upgrade @@ -116,6 +121,19 @@ jobs: env: FORGE_MIGRATION_0026_DATABASE_URL: postgresql://forge_migration_test:forge_migration_test@localhost:5432/forge_migration_0026_upgrade_test FORGE_DATABASE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_migration_0026_upgrade_test + - name: Prove real installer-managed migration sequence and S5 cleanup + working-directory: . + run: bash scripts/ci/prove-installer-managed-migrations.sh + env: + FORGE_INSTALLER_MANAGED_DATABASE_URL: postgresql://forge_migration_test:forge_migration_test@localhost:5432/forge_installer_managed_migration_test + FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL: postgresql://forge_migration_test:forge_migration_test@localhost:5432/forge_installer_managed_s5_failure_test + FORGE_INSTALLER_MANAGED_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_installer_managed_migration_test + FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_installer_managed_s5_failure_test + FORGE_INSTALLER_MANAGED_ADMIN_HOST: localhost + FORGE_INSTALLER_MANAGED_ADMIN_USER: forge_e2e + FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD: forge + FORGE_INSTALLER_MANAGED_ADMIN_DATABASE: forge_installer_managed_migration_test + FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_DATABASE: forge_installer_managed_s5_failure_test - name: Prove populated migration 0026 to latest recovery upgrade and strict cutover run: npm run test:migration-0027-upgrade env: diff --git a/scripts/ci/prove-installer-managed-migrations.sh b/scripts/ci/prove-installer-managed-migrations.sh new file mode 100755 index 00000000..d2f744af --- /dev/null +++ b/scripts/ci/prove-installer-managed-migrations.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Hosted disposable-PostgreSQL proof for the installer-managed migration path. +set -Eeuo pipefail + +: "${FORGE_INSTALLER_MANAGED_DATABASE_URL:?Set the disposable successful-upgrade database URL.}" +: "${FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL:?Set the disposable S5-failure database URL.}" +: "${FORGE_INSTALLER_MANAGED_ADMIN_URL:?Set the successful-upgrade PostgreSQL administrator URL.}" +: "${FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL:?Set the S5-failure PostgreSQL administrator URL.}" +: "${FORGE_INSTALLER_MANAGED_ADMIN_HOST:?Set the fixed disposable PostgreSQL admin host.}" +: "${FORGE_INSTALLER_MANAGED_ADMIN_USER:?Set the fixed disposable PostgreSQL admin user.}" +: "${FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD:?Set the fixed disposable PostgreSQL admin password.}" +: "${FORGE_INSTALLER_MANAGED_ADMIN_DATABASE:?Set the successful-upgrade admin database name.}" +: "${FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_DATABASE:?Set the S5-failure admin database name.}" + +SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -P "$SCRIPT_DIR/../.." && pwd)" +INSTALLER="$REPO_ROOT/scripts/install.sh" +TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/forge-installer-managed-proof.XXXXXX")" +trap 'rm -rf "$TEMP_ROOT"' EXIT + +prepare_0025_baseline() { + local database_url="$1" admin_url="$2" + echo 'Preparing exact migration-0025 disposable baseline.' + ( + cd "$REPO_ROOT/web" + DATABASE_URL="$database_url" FORGE_DATABASE_ADMIN_URL="$admin_url" \ + npm run protocol:bootstrap-epic-172-release-roles + DATABASE_URL="$database_url" npx tsx scripts/ci/migrate-through-0025.ts + ) +} + +run_real_managed_sequence() { + local database_url="$1" admin_url="$2" env_file="$3" + printf 'DATABASE_URL=%s\n' "$database_url" > "$env_file" + ( + set -- + export DATABASE_URL="$database_url" + export FORGE_DATABASE_ADMIN_URL="$admin_url" + export FORGE_ENV_FILE="$env_file" + export FORGE_INSTALL_LIBRARY=1 + source "$INSTALLER" + MANAGED_LOCAL_ADMIN_MODE=current + run_managed_local_migration_sequence + ) +} + +assert_latest_and_clean() { + local database_name="$1" + PGPASSWORD="$FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD" PGHOST="$FORGE_INSTALLER_MANAGED_ADMIN_HOST" PGUSER="$FORGE_INSTALLER_MANAGED_ADMIN_USER" PGDATABASE="$database_name" psql --set ON_ERROR_STOP=1 <<'SQL' +DO $proof$ +BEGIN + IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 29 + OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1784274000000 THEN + RAISE EXCEPTION 'Managed installer did not apply the exact latest migration ledger'; + END IF; + IF pg_catalog.to_regclass('public.forge_epic_172_s3_release_state') IS NULL THEN + RAISE EXCEPTION 'Managed installer did not create the S3 release state'; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_auth_members membership + WHERE membership.roleid IN ('forge_release_routines_owner'::regrole, 'forge_s4_routines_owner'::regrole) + ) THEN + RAISE EXCEPTION 'Managed installer retained owner membership'; + END IF; +END; +$proof$; +SQL +} + +assert_s5_cleanup() { + local database_name="$1" + PGPASSWORD="$FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD" PGHOST="$FORGE_INSTALLER_MANAGED_ADMIN_HOST" PGUSER="$FORGE_INSTALLER_MANAGED_ADMIN_USER" PGDATABASE="$database_name" psql --set ON_ERROR_STOP=1 <<'SQL' +DO $proof$ +DECLARE + migration_login name := 'forge_migration_test'; +BEGIN + IF pg_catalog.has_function_privilege( + migration_login, + 'public.forge_begin_epic_172_s4_owner_bootstrap_v1()'::regprocedure, + 'EXECUTE' + ) OR pg_catalog.pg_has_role(migration_login, 'forge_s4_routines_owner', 'member') + OR pg_catalog.has_schema_privilege(migration_login, 'forge', 'CREATE') THEN + RAISE EXCEPTION 'S5 cleanup retained temporary migration authority'; + END IF; +END; +$proof$; +SQL +} + +success_env="$TEMP_ROOT/success.env" +failure_env="$TEMP_ROOT/failure.env" + +echo 'Proving the real installer-managed sequence from migration 0025 through latest.' +prepare_0025_baseline "$FORGE_INSTALLER_MANAGED_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_ADMIN_URL" +run_real_managed_sequence "$FORGE_INSTALLER_MANAGED_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_ADMIN_URL" "$success_env" +assert_latest_and_clean "$FORGE_INSTALLER_MANAGED_ADMIN_DATABASE" + +echo 'Re-running the real installer-managed sequence to prove already-latest idempotency.' +run_real_managed_sequence "$FORGE_INSTALLER_MANAGED_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_ADMIN_URL" "$success_env" +assert_latest_and_clean "$FORGE_INSTALLER_MANAGED_ADMIN_DATABASE" + +echo 'Proving real S5 failure cleanup preserves the original migration failure.' +prepare_0025_baseline "$FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL" +set +e +FORGE_S5_FORCE_HANDOFF_FAILURE=1 \ + run_real_managed_sequence "$FORGE_INSTALLER_MANAGED_FAILURE_DATABASE_URL" "$FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_URL" "$failure_env" +failure_status=$? +set -e +if [ "$failure_status" -eq 0 ]; then + echo 'The induced S5 failure unexpectedly succeeded.' >&2 + exit 1 +fi +assert_s5_cleanup "$FORGE_INSTALLER_MANAGED_FAILURE_ADMIN_DATABASE" + +echo 'Installer-managed migration sequence, rerun, and S5 failure-cleanup proof passed.' diff --git a/scripts/install.sh b/scripts/install.sh index c9d4b4e7..2341605c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -16,7 +16,7 @@ # set -Eeuo pipefail -SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_DIR="$(cd -P "${BASH_SOURCE[0]%/*}" && pwd)" REPO_ROOT="$(cd -P "$SCRIPT_DIR/.." && pwd)" expand_home_path_early() { case "${1:-}" in @@ -161,8 +161,9 @@ cleanup() { } trap cleanup EXIT -while [ "$#" -gt 0 ]; do - case "$1" in +if ! { [ "${FORGE_INSTALL_LIBRARY:-0}" = "1" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; }; then + while [ "$#" -gt 0 ]; do + case "$1" in --skip-ollama) SKIP_OLLAMA=1 ;; @@ -207,9 +208,10 @@ while [ "$#" -gt 0 ]; do *) die "Unknown option: $1" ;; - esac - shift -done + esac + shift + done +fi case "$SERVICE_MODE" in auto|native|docker) ;; @@ -1406,11 +1408,244 @@ prepare_web_app() { bash -c 'cd "$1" && npm install --no-audit --no-fund --progress=true' _ "$REPO_ROOT/web" mark_web_node_modules_clean fi - run "npm run db:migrate" bash -c 'cd "$1" && FORGE_WORKSPACE_ROOT="$2" FORGE_ENV_FILE="$3" FORGE_SUPPRESS_MIGRATION_NOTICES=1 npm run db:migrate --silent' _ "$REPO_ROOT/web" "$WORKSPACE_ROOT" "$ENV_FILE" + if managed_local_migrations_enabled; then + run_managed_local_migrations + else + run "npm run db:migrate" bash -c 'cd "$1" && FORGE_WORKSPACE_ROOT="$2" FORGE_ENV_FILE="$3" FORGE_SUPPRESS_MIGRATION_NOTICES=1 npm run db:migrate --silent' _ "$REPO_ROOT/web" "$WORKSPACE_ROOT" "$ENV_FILE" + fi grant_forge_privileges run "npm run db:seed-agents" bash -c 'cd "$1" && FORGE_WORKSPACE_ROOT="$2" FORGE_ENV_FILE="$3" FORGE_PROMPT_UPGRADE_MODE="$4" npm run db:seed-agents' _ "$REPO_ROOT/web" "$WORKSPACE_ROOT" "$ENV_FILE" "$PROMPT_UPGRADE_MODE" } +managed_local_migrations_enabled() { + [ "$SERVICE_MODE" = "native" ] || return 1 + should_manage_local_db + [ "$MANAGE_LOCAL_DB" = "1" ] +} + +resolve_managed_local_admin() { + local psql_bin sudo_bin runuser_bin + MANAGED_LOCAL_ADMIN_MODE="" + MANAGED_LOCAL_ADMIN_SOCKET="" + MANAGED_LOCAL_ADMIN_USER="" + + if [ -n "${FORGE_INSTALL_TEST_ADMIN_MODE:-}" ]; then + case "$FORGE_INSTALL_TEST_ADMIN_MODE" in + current|sudo|runuser) MANAGED_LOCAL_ADMIN_MODE="$FORGE_INSTALL_TEST_ADMIN_MODE" ;; + unavailable) return 1 ;; + *) die "Unknown installer test admin mode." ;; + esac + MANAGED_LOCAL_ADMIN_SOCKET="/tmp" + MANAGED_LOCAL_ADMIN_USER="postgres" + return 0 + fi + + if psql -d postgres -tAc 'SELECT current_user' >/dev/null 2>&1; then + MANAGED_LOCAL_ADMIN_MODE="current" + MANAGED_LOCAL_ADMIN_USER="$(psql -d postgres -tAc 'SELECT current_user' | tr -d '[:space:]')" + MANAGED_LOCAL_ADMIN_SOCKET="$(psql -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" + elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 \ + && sudo_bin="$(trusted_linux_tool sudo)" && psql_bin="$(trusted_linux_tool psql)" \ + && "$sudo_bin" -n -u postgres "$psql_bin" -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then + MANAGED_LOCAL_ADMIN_MODE="sudo" + MANAGED_LOCAL_ADMIN_USER="postgres" + MANAGED_LOCAL_ADMIN_SOCKET="$("$sudo_bin" -n -u postgres "$psql_bin" -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" + elif [ "$OS_NAME" = "Linux" ] && id postgres >/dev/null 2>&1 \ + && runuser_bin="$(trusted_linux_tool runuser)" && psql_bin="$(trusted_linux_tool psql)" \ + && "$runuser_bin" -u postgres -- "$psql_bin" -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then + MANAGED_LOCAL_ADMIN_MODE="runuser" + MANAGED_LOCAL_ADMIN_USER="postgres" + MANAGED_LOCAL_ADMIN_SOCKET="$("$runuser_bin" -u postgres -- "$psql_bin" -d postgres -tAc 'SHOW unix_socket_directories' | tr -d '[:space:]' | cut -d, -f1)" + else + return 1 + fi + + [ -n "$MANAGED_LOCAL_ADMIN_USER" ] && [ -n "$MANAGED_LOCAL_ADMIN_SOCKET" ] +} + +run_managed_local_migration_stage() { + local description="$1" stage="$2" + shift 2 + + if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations" ]; then + printf '%s\n' "$stage" >> "${FORGE_INSTALL_TEST_STAGE_LOG:?}" + if [ "${FORGE_INSTALL_TEST_FAIL_STAGE:-}" = "$stage" ]; then + [ "$stage" != "s5" ] || printf 's5-cleanup-attempted\n' >> "${FORGE_INSTALL_TEST_STAGE_LOG:?}" + return 1 + fi + return 0 + fi + + case "$MANAGED_LOCAL_ADMIN_MODE" in + current) + run "$description" bash -c 'cd "$1"; case "$2" in + release) npm run protocol:bootstrap-epic-172-release-roles ;; + migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; + s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; + migrate-0026) npx tsx scripts/ci/migrate-through-0026.ts ;; + s4) npm run protocol:bootstrap-epic-172-s4-roles ;; + migrate-0027) npx tsx scripts/ci/migrate-through-0027.ts ;; + s5) bash scripts/ci/apply-epic-172-s5-recovery-migration.sh ;; + latest) npm run db:migrate ;; + *) exit 64 ;; + esac' _ "$REPO_ROOT/web" "$stage" + ;; + sudo) + run_managed_local_migration_as_sudo "$description" "$stage" + ;; + runuser) + run_managed_local_migration_as_runuser "$description" "$stage" + ;; + *) die "Managed local PostgreSQL administrator mode is unavailable." ;; + esac +} + +trusted_linux_path_chain() { + local path="$1" owner mode + while :; do + [ -e "$path" ] || [ -L "$path" ] || return 1 + owner="$(/usr/bin/stat -c '%U' "$path" 2>/dev/null || true)" + [ "$owner" = root ] || return 1 + if [ ! -L "$path" ]; then + mode="$(/usr/bin/stat -c '%a' "$path" 2>/dev/null || true)" + [ -n "$mode" ] && [ $((8#$mode & 022)) -eq 0 ] || return 1 + fi + [ "$path" = / ] && return 0 + path="${path%/*}" + [ -n "$path" ] || path=/ + done +} + +canonicalize_trusted_linux_candidate() { + /usr/bin/readlink -f "$1" 2>/dev/null +} + +trusted_linux_candidate() { + local candidate="$1" canonical + [ -x "$candidate" ] || return 1 + trusted_linux_path_chain "$candidate" || return 1 + canonical="$(canonicalize_trusted_linux_candidate "$candidate")" || return 1 + [ -n "$canonical" ] && [ -f "$canonical" ] || return 1 + trusted_linux_path_chain "$canonical" || return 1 + printf '%s\n' "$canonical" +} + +trusted_linux_tool() { + local tool="$1" candidate + for candidate in "/usr/local/sbin/$tool" "/usr/local/bin/$tool" "/usr/sbin/$tool" "/usr/bin/$tool" "/sbin/$tool" "/bin/$tool"; do + trusted_linux_candidate "$candidate" && return 0 + done + return 1 +} + +prepare_trusted_linux_migration_toolchain() { + local tool directory path_result="" + [ "$OS_NAME" = Linux ] || die "Elevated managed local migrations are only supported on Linux." + MANAGED_LOCAL_BASH="$(trusted_linux_tool bash)" || die "Could not find a root-owned non-writable bash for elevated managed migrations." + MANAGED_LOCAL_NODE="$(trusted_linux_tool node)" || die "Could not find a root-owned non-writable node for elevated managed migrations." + MANAGED_LOCAL_NPM="$(trusted_linux_tool npm)" || die "Could not find a root-owned non-writable npm for elevated managed migrations." + MANAGED_LOCAL_NPX="$(trusted_linux_tool npx)" || die "Could not find a root-owned non-writable npx for elevated managed migrations." + for tool in "$MANAGED_LOCAL_NODE" "$MANAGED_LOCAL_NPM" "$MANAGED_LOCAL_NPX" "$MANAGED_LOCAL_BASH"; do + directory="${tool%/*}" + case ":$path_result:" in + *":$directory:"*) ;; + *) path_result="${path_result:+$path_result:}$directory" ;; + esac + done + MANAGED_LOCAL_PATH="$path_result" +} + +run_managed_local_migration_as_runuser() { + local description="$1" stage="$2" + local controlled_path runuser_bin + prepare_trusted_linux_migration_toolchain + controlled_path="$MANAGED_LOCAL_PATH" + runuser_bin="$(trusted_linux_tool runuser)" || die "Could not find a root-owned non-writable runuser for elevated managed migrations." + + # `runuser --preserve-environment` is needed so the child receives the + # process-only socket/admin context without serialising a credential into an + # argv value. Constrain its parent environment first: this subshell exports + # exactly the migration inputs and a controlled executable path. + ( + local name + for name in $(compgen -e); do + case "$name" in + DATABASE_URL|FORGE_DATABASE_ADMIN_URL|PGHOST|PGUSER|FORGE_WORKSPACE_ROOT|FORGE_ENV_FILE|FORGE_SUPPRESS_MIGRATION_NOTICES|PATH) ;; + *) unset "$name" ;; + esac + done + PATH="$controlled_path" + export PATH + run "$description" "$runuser_bin" -u postgres --preserve-environment -- "$MANAGED_LOCAL_BASH" -c 'cd "$1"; case "$2" in + release) npm run protocol:bootstrap-epic-172-release-roles ;; + migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; + s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; + migrate-0026) npx tsx scripts/ci/migrate-through-0026.ts ;; + s4) npm run protocol:bootstrap-epic-172-s4-roles ;; + migrate-0027) npx tsx scripts/ci/migrate-through-0027.ts ;; + s5) bash scripts/ci/apply-epic-172-s5-recovery-migration.sh ;; + latest) npm run db:migrate ;; + *) exit 64 ;; + esac' _ "$REPO_ROOT/web" "$stage" + ) +} + +run_managed_local_migration_as_sudo() { + local description="$1" stage="$2" + local sudo_bin + prepare_trusted_linux_migration_toolchain + sudo_bin="$(trusted_linux_tool sudo)" || die "Could not find a root-owned non-writable sudo for elevated managed migrations." + ( + PATH="$MANAGED_LOCAL_PATH" + export PATH + "$sudo_bin" -n -u postgres --preserve-env=DATABASE_URL,FORGE_DATABASE_ADMIN_URL,PGHOST,PGUSER,FORGE_WORKSPACE_ROOT,FORGE_ENV_FILE,FORGE_SUPPRESS_MIGRATION_NOTICES,PATH "$MANAGED_LOCAL_BASH" -c 'cd "$1"; case "$2" in + release) npm run protocol:bootstrap-epic-172-release-roles ;; + migrate-0025) npx tsx scripts/ci/migrate-through-0025.ts ;; + s3) npm run protocol:bootstrap-epic-172-s3-release-owner ;; + migrate-0026) npx tsx scripts/ci/migrate-through-0026.ts ;; + s4) npm run protocol:bootstrap-epic-172-s4-roles ;; + migrate-0027) npx tsx scripts/ci/migrate-through-0027.ts ;; + s5) bash scripts/ci/apply-epic-172-s5-recovery-migration.sh ;; + latest) npm run db:migrate ;; + *) exit 64 ;; + esac' _ "$REPO_ROOT/web" "$stage" + ) +} + +run_managed_local_migrations() { + step "Applying managed local database migrations" + if [ "$DRY_RUN" = "1" ]; then + info "[dry-run] Bootstrap release roles, migrate through 0025, bootstrap S3, migrate through 0026, bootstrap S4, migrate through 0027, apply S5 with cleanup, then run the latest migrator." + return 0 + fi + + resolve_managed_local_admin || die "Could not establish passwordless local PostgreSQL administrator access for managed migrations. Use a native local PostgreSQL peer login, or run the documented operator migration procedure for a custom database." + + local DATABASE_URL FORGE_DATABASE_ADMIN_URL PGHOST PGUSER FORGE_WORKSPACE_ROOT FORGE_ENV_FILE FORGE_SUPPRESS_MIGRATION_NOTICES + DATABASE_URL="$(env_value DATABASE_URL)" + [ -n "$DATABASE_URL" ] || die "Managed local migrations require DATABASE_URL in the local Forge environment file." + FORGE_DATABASE_ADMIN_URL="postgresql:///forge" + PGHOST="$MANAGED_LOCAL_ADMIN_SOCKET" + PGUSER="$MANAGED_LOCAL_ADMIN_USER" + FORGE_WORKSPACE_ROOT="$WORKSPACE_ROOT" + FORGE_ENV_FILE="$ENV_FILE" + FORGE_SUPPRESS_MIGRATION_NOTICES=1 + export DATABASE_URL FORGE_DATABASE_ADMIN_URL PGHOST PGUSER FORGE_WORKSPACE_ROOT FORGE_ENV_FILE FORGE_SUPPRESS_MIGRATION_NOTICES + + run_managed_local_migration_sequence +} + +run_managed_local_migration_sequence() { + run_managed_local_migration_stage "Bootstrap release roles for managed local migration" release || die "Managed local migration failed while bootstrapping release roles." + run_managed_local_migration_stage "Migrate managed local database through 0025" migrate-0025 || die "Managed local migration failed while applying migrations through 0025." + run_managed_local_migration_stage "Bootstrap S3 owner handoff for managed local migration" s3 || die "Managed local migration failed while bootstrapping the S3 owner handoff." + run_managed_local_migration_stage "Migrate managed local database through 0026" migrate-0026 || die "Managed local migration failed while applying migrations through 0026." + run_managed_local_migration_stage "Bootstrap S4 owner handoff for managed local migration" s4 || die "Managed local migration failed while bootstrapping the S4 owner handoff." + run_managed_local_migration_stage "Migrate managed local database through 0027" migrate-0027 || die "Managed local migration failed while applying migrations through 0027." + run_managed_local_migration_stage "Apply S5 managed local migration with mandatory cleanup" s5 || die "Managed local migration failed while applying S5; its cleanup wrapper preserves the original migration failure." + run_managed_local_migration_stage "Run the latest managed local migrator" latest || die "Managed local migration failed while applying the latest migration set." +} + run_doctor() { step "Running the doctor" run "npm run doctor" bash -c 'cd "$1" && FORGE_WORKSPACE_ROOT="$2" FORGE_ENV_FILE="$3" npm run doctor' _ "$REPO_ROOT/web" "$WORKSPACE_ROOT" "$ENV_FILE" @@ -1683,6 +1918,10 @@ acquire_install_lock() { die "Another Forge install appears to be running. Remove $LOCK_DIR only if you are sure it is stale." } +if [ "${FORGE_INSTALL_LIBRARY:-0}" = "1" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then + return 0 +fi + # Internal seam for the focused installer regression test. It intentionally # bypasses all setup work and invokes only the native-service function. if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "start-native-services" ]; then @@ -1692,6 +1931,23 @@ if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "start-native-services" ]; then exit 0 fi +if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations" ]; then + SERVICE_MODE="${FORGE_INSTALL_TEST_SERVICE_MODE:-native}" + printf 'admin:%s\n' "${FORGE_INSTALL_TEST_ADMIN_MODE:-unset}" >> "${FORGE_INSTALL_TEST_STAGE_LOG:?}" + run_managed_local_migrations + exit 0 +fi + +if [ "${FORGE_INSTALL_TEST_HOOK:-}" = "managed-local-migrations-enabled" ]; then + SERVICE_MODE="${FORGE_INSTALL_TEST_SERVICE_MODE:-native}" + if managed_local_migrations_enabled; then + printf 'managed-local-migrations-enabled\n' + else + printf 'managed-local-migrations-bypassed\n' + fi + exit 0 +fi + bold "Forge installer" info "Repo: $REPO_ROOT" info "OS: $OS_NAME" diff --git a/scripts/test-install-managed-migrations.sh b/scripts/test-install-managed-migrations.sh new file mode 100755 index 00000000..4bbd5172 --- /dev/null +++ b/scripts/test-install-managed-migrations.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# Focused executable coverage for managed-local migration orchestration. +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALLER="$SCRIPT_DIR/install.sh" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/forge-managed-migrations.XXXXXX")" +trap 'rm -rf "$TEST_ROOT"' EXIT +TEST_SECRET='TEST_APP_DATABASE_URL_MUST_NOT_APPEAR' + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_contains() { + grep -Fq "$1" "$2" || fail "expected '$1' in $2" +} + +assert_not_contains() { + ! grep -Fq "$1" "$2" || fail "did not expect '$1' in $2" +} + +assert_stages() { + local file="$1" + shift + local expected actual + expected="$(printf '%s\n' "$@")" + actual="$(sed -n '2,$p' "$file")" + [ "$actual" = "$expected" ] || fail "unexpected stages in $file: $actual" +} + +run_managed_case() { + local name="$1" admin_mode="$2" dry_run="${3:-0}" fail_stage="${4:-}" + local case_dir="$TEST_ROOT/$name" + mkdir -p "$case_dir/state" + printf 'DATABASE_URL=postgresql://forge:%s@localhost:5432/forge\n' "$TEST_SECRET" > "$case_dir/forge.env" + : > "$case_dir/stages" + set +e + FORGE_INSTALL_TEST_HOOK=managed-local-migrations \ + FORGE_INSTALL_TEST_ADMIN_MODE="$admin_mode" \ + FORGE_INSTALL_TEST_STAGE_LOG="$case_dir/stages" \ + FORGE_INSTALL_TEST_FAIL_STAGE="$fail_stage" \ + FORGE_DRY_RUN="$dry_run" \ + FORGE_ENV_FILE="$case_dir/forge.env" \ + FORGE_INSTALL_STATE_DIR="$case_dir/state" \ + /bin/bash "$INSTALLER" > "$case_dir/stdout" 2> "$case_dir/stderr" + CASE_STATUS=$? + set -e + CASE_DIR="$case_dir" +} + +expected_stages=(release migrate-0025 s3 migrate-0026 s4 migrate-0027 s5 latest) + +for admin_mode in current sudo runuser; do + run_managed_case "$admin_mode" "$admin_mode" + [ "$CASE_STATUS" -eq 0 ] || fail "$admin_mode managed migration should succeed" + assert_contains "admin:$admin_mode" "$CASE_DIR/stages" + assert_stages "$CASE_DIR/stages" "${expected_stages[@]}" +done + +run_managed_case idempotent-first current +[ "$CASE_STATUS" -eq 0 ] || fail 'first idempotent orchestration run should succeed' +run_managed_case idempotent-second current +[ "$CASE_STATUS" -eq 0 ] || fail 'second idempotent orchestration run should succeed' +assert_stages "$CASE_DIR/stages" "${expected_stages[@]}" + +run_managed_case dry-run current 1 +[ "$CASE_STATUS" -eq 0 ] || fail 'dry-run should succeed' +assert_contains '[dry-run] Bootstrap release roles, migrate through 0025' "$CASE_DIR/stdout" +[ "$(wc -l < "$CASE_DIR/stages" | tr -d '[:space:]')" = 1 ] || fail 'dry-run must not execute migration stages' + +run_managed_case admin-unavailable unavailable +[ "$CASE_STATUS" -ne 0 ] || fail 'unavailable local admin must fail closed' +assert_contains 'Could not establish passwordless local PostgreSQL administrator access' "$CASE_DIR/stderr" + +run_managed_case s5-failure current 0 s5 +[ "$CASE_STATUS" -ne 0 ] || fail 'S5 migration failure must fail the orchestration' +assert_contains 's5-cleanup-attempted' "$CASE_DIR/stages" +assert_not_contains 'latest' "$CASE_DIR/stages" +assert_contains 'its cleanup wrapper preserves the original migration failure' "$CASE_DIR/stderr" + +run_enabled_case() { + local name="$1" service_mode="$2" database_url="$3" + local case_dir="$TEST_ROOT/$name" + mkdir -p "$case_dir/state" + printf 'DATABASE_URL=%s\n' "$database_url" > "$case_dir/forge.env" + FORGE_INSTALL_TEST_HOOK=managed-local-migrations-enabled \ + FORGE_INSTALL_TEST_SERVICE_MODE="$service_mode" \ + FORGE_ENV_FILE="$case_dir/forge.env" \ + FORGE_INSTALL_STATE_DIR="$case_dir/state" \ + /bin/bash "$INSTALLER" > "$case_dir/stdout" 2> "$case_dir/stderr" + CASE_DIR="$case_dir" +} + +run_enabled_case custom native "postgresql://custom:${TEST_SECRET}@example.invalid/forge" +assert_contains 'managed-local-migrations-bypassed' "$CASE_DIR/stdout" +run_enabled_case docker docker "postgresql://forge:${TEST_SECRET}@localhost:5432/forge" +assert_contains 'managed-local-migrations-bypassed' "$CASE_DIR/stdout" + +run_runuser_environment_case() { + local case_dir="$TEST_ROOT/runuser-environment" + mkdir -p "$case_dir/bin" "$case_dir/state" + printf 'DATABASE_URL=postgresql://forge:%s@localhost:5432/forge\n' "$TEST_SECRET" > "$case_dir/forge.env" + cat > "$case_dir/bin/runuser" <<'EOF' +#!/bin/bash +marker_dir="${FORGE_ENV_FILE%/*}" +if [ -n "${UNRELATED_SECRET_SENTINEL+x}" ]; then + printf 'unrelated-secret-leaked\n' > "$marker_dir/runuser-result" + exit 1 +fi +compgen -e > "$marker_dir/runuser-environment-names" +printf 'clean\n' > "$marker_dir/runuser-result" +while [ "$1" != "--" ]; do shift; done +shift +exec "$@" +EOF + cat > "$case_dir/bin/bash" <<'EOF' +#!/bin/bash +exit 0 +EOF + for command in node npm npx; do + printf '#!/usr/bin/env bash\nexit 0\n' > "$case_dir/bin/$command" + done + chmod +x "$case_dir/bin/runuser" "$case_dir/bin/bash" "$case_dir/bin/node" "$case_dir/bin/npm" "$case_dir/bin/npx" + cat > "$case_dir/driver.sh" <<'EOF' +#!/bin/bash +FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" +test_toolchain_dir="$FORGE_TEST_TOOLCHAIN_DIR" +trusted_linux_tool() { printf '%s/%s\n' "$test_toolchain_dir" "$1"; } +OS_NAME=Linux +SERVICE_MODE=native +DRY_RUN=0 +run_managed_local_migrations +EOF + chmod +x "$case_dir/driver.sh" + set +e + INSTALLER="$INSTALLER" \ + FORGE_TEST_TOOLCHAIN_DIR="$case_dir/bin" \ + PATH="$case_dir/bin:$PATH" \ + UNRELATED_SECRET_SENTINEL='unrelated-value-must-not-reach-postgres' \ + FORGE_INSTALL_TEST_ADMIN_MODE=runuser \ + FORGE_ENV_FILE="$case_dir/forge.env" \ + FORGE_INSTALL_STATE_DIR="$case_dir/state" \ + /bin/bash "$case_dir/driver.sh" > "$case_dir/stdout" 2> "$case_dir/stderr" + local driver_status=$? + set -e + if [ "$driver_status" -ne 0 ]; then + sed -n '1,80p' "$case_dir/stderr" >&2 + fail 'runuser environment driver failed' + fi + CASE_DIR="$case_dir" +} + +run_runuser_environment_case +assert_contains 'clean' "$CASE_DIR/runuser-result" +assert_not_contains 'UNRELATED_SECRET_SENTINEL' "$CASE_DIR/runuser-environment-names" +assert_contains 'DATABASE_URL' "$CASE_DIR/runuser-environment-names" +assert_contains 'FORGE_DATABASE_ADMIN_URL' "$CASE_DIR/runuser-environment-names" +assert_contains 'PGHOST' "$CASE_DIR/runuser-environment-names" +assert_contains 'PGUSER' "$CASE_DIR/runuser-environment-names" +assert_not_contains "$TEST_SECRET" "$CASE_DIR/runuser-environment-names" + +run_shadow_refusal_case() { + local case_dir="$TEST_ROOT/shadow-refusal" + mkdir -p "$case_dir/bin" + local command + for command in dirname node npm npx bash sudo runuser; do + printf '#!/bin/bash\nprintf shadow-used > "$FORGE_SHADOW_MARKER"\nexit 0\n' > "$case_dir/bin/$command" + done + chmod +x "$case_dir/bin"/* + set +e + INSTALLER="$INSTALLER" FORGE_SHADOW_MARKER="$case_dir/marker" FORGE_SHADOW_DIRECTORY="$case_dir/bin" PATH="$case_dir/bin:$PATH" /bin/bash -c ' + FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" + OS_NAME=Linux + prepare_trusted_linux_migration_toolchain + [ ":$MANAGED_LOCAL_PATH:" != *":$FORGE_SHADOW_DIRECTORY:"* ] + ' > "$case_dir/stdout" 2> "$case_dir/stderr" + local shadow_status=$? + set -e + if [ "$shadow_status" -eq 0 ]; then + assert_not_contains "$case_dir/bin" "$case_dir/stdout" + fi + [ ! -e "$case_dir/marker" ] || fail 'caller PATH shadow reached the privileged sudo resolver' +} + +run_shadow_refusal_case + +run_library_argument_case() { + local case_dir="$TEST_ROOT/library-arguments" + mkdir -p "$case_dir" + INSTALLER="$INSTALLER" /bin/bash -c ' + set -- postgresql://argument-must-not-reach-installer + FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" + [ "$#" -eq 1 ] + ( + set -- + FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" + [ "$#" -eq 0 ] + declare -F run_managed_local_migration_sequence >/dev/null + ) + ' > "$case_dir/stdout" 2> "$case_dir/stderr" || fail 'library source did not ignore caller positional arguments' +} + +run_library_argument_case + +run_trusted_candidate_toc_tou_case() { + local case_dir="$TEST_ROOT/trusted-candidate-toc-tou" + mkdir -p "$case_dir" + : > "$case_dir/target-one" + : > "$case_dir/target-two" + chmod +x "$case_dir/target-one" "$case_dir/target-two" + ln -s "$case_dir/target-one" "$case_dir/candidate" + INSTALLER="$INSTALLER" \ + FORGE_TOC_TOU_CANDIDATE="$case_dir/candidate" \ + FORGE_TOC_TOU_TARGET_ONE="$case_dir/target-one" \ + FORGE_TOC_TOU_TARGET_TWO="$case_dir/target-two" \ + FORGE_TOC_TOU_COUNT="$case_dir/canonicalizer-count" \ + /bin/bash -c ' + FORGE_INSTALL_LIBRARY=1 source "$INSTALLER" + trusted_linux_path_chain() { return 0; } + canonicalize_trusted_linux_candidate() { + printf x >> "$FORGE_TOC_TOU_COUNT" + if [ "$(<"$FORGE_TOC_TOU_COUNT")" = x ]; then + printf "%s\\n" "$FORGE_TOC_TOU_TARGET_ONE" + else + printf "%s\\n" "$FORGE_TOC_TOU_TARGET_TWO" + fi + } + resolved="$(trusted_linux_candidate "$FORGE_TOC_TOU_CANDIDATE")" + [ "$resolved" = "$FORGE_TOC_TOU_TARGET_ONE" ] + [ "$(<"$FORGE_TOC_TOU_COUNT")" = x ] + trusted_linux_path_chain() { + [ "$1" = "$FORGE_TOC_TOU_CANDIDATE" ] && [ -L "$1" ] && return 1 + return 0 + } + rm -f "$FORGE_TOC_TOU_COUNT" + ! trusted_linux_candidate "$FORGE_TOC_TOU_CANDIDATE" + [ ! -e "$FORGE_TOC_TOU_COUNT" ] + ' > "$case_dir/stdout" 2> "$case_dir/stderr" || fail 'trusted candidate canonicalization was not single-resolution and fail-closed' +} + +run_trusted_candidate_toc_tou_case + +if rg -F "$TEST_SECRET" "$TEST_ROOT" --glob '!forge.env' >/dev/null; then + fail 'test sentinel leaked outside the local environment fixture' +fi + +printf 'PASS: managed local migration orchestration coverage\n'