Files
Ludarium-Public/deploy/run-candidate-gate.sh
T
Ludarium release export df869819ce
Public source validation / source (push) Successful in 2m16s
Publish Ludarium source
2026-09-03 02:08:58 +02:00

256 lines
16 KiB
Bash

#!/bin/sh
set -eu
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
compose="$repo/deploy/compose.yml"
project="${LUDARIUM_CANDIDATE_PROJECT:-ludarium-candidate}"
candidate_port="${LUDARIUM_CANDIDATE_PORT:-1232}"
data_root="${LUDARIUM_CANDIDATE_DATA_ROOT:?set an isolated candidate data root}"
release_identity="$(tr -d '\r\n' < "$repo/VERSION")"
image="${LUDARIUM_IMAGE:-ludarium/ludarium:$release_identity}"
release_version="${LUDARIUM_RELEASE_VERSION:-${image##*:}}"
playwright_image="mcr.microsoft.com/playwright:v1.62.1-noble@sha256:dcc5531e97840b9b5e794f2814476b21571c5124a3fca2267d73041f56e7580e"
browser_gate_mode="${LUDARIUM_BROWSER_GATE_MODE:-docker}"
dotnet_image="mcr.microsoft.com/dotnet/sdk:10.0.302@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0"
gate_cpus="${LUDARIUM_GATE_CPUS:-4}"
gate_memory="${LUDARIUM_GATE_MEMORY:-8g}"
browser_gate_cpus="${LUDARIUM_BROWSER_GATE_CPUS:-4}"
browser_gate_memory="${LUDARIUM_BROWSER_GATE_MEMORY:-4g}"
build_nodes="${LUDARIUM_GATE_BUILD_NODES:-2}"
export COMPOSE_PARALLEL_LIMIT="${LUDARIUM_COMPOSE_PARALLEL_LIMIT:-1}"
case "$browser_gate_mode" in docker|external) ;; *) echo "browser gate mode must be docker or external" >&2; exit 2;; esac
case "$gate_cpus:$browser_gate_cpus:$build_nodes:$COMPOSE_PARALLEL_LIMIT" in
*[!0-9.:]*) echo "candidate resource limits must be positive numeric values" >&2; exit 2 ;;
esac
for value in "$gate_cpus" "$browser_gate_cpus" "$build_nodes" "$COMPOSE_PARALLEL_LIMIT"; do
[ "$value" != "0" ] || { echo "candidate resource limits must be greater than zero" >&2; exit 2; }
done
case "$gate_memory:$browser_gate_memory" in
*[!0-9kKmMgGtT:]*|:) echo "candidate memory limits must use Docker byte values such as 8g" >&2; exit 2 ;;
esac
case "$data_root" in
/mnt/user/appdata/ludarium-candidate|/mnt/user/appdata/ludarium-candidate/*|/tmp/ludarium-candidate|/tmp/ludarium-candidate/*) ;;
*) echo "candidate data root must remain below /mnt/user/appdata/ludarium-candidate or /tmp/ludarium-candidate" >&2; exit 2 ;;
esac
: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}"
: "${LUDARIUM_ADMIN_TOKEN:?set LUDARIUM_ADMIN_TOKEN}"
: "${GAMES_LIBRARY:?set GAMES_LIBRARY}"
: "${PS4_LIBRARY:?set PS4_LIBRARY}"
: "${PS5_LIBRARY:?set PS5_LIBRARY}"
: "${PUID:?set the numeric read-capable application UID}"
: "${PGID:?set the numeric read-capable application GID}"
for root in "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY"; do
[ -d "$root" ] || { echo "library root unavailable: $root" >&2; exit 3; }
done
command -v docker >/dev/null || { echo "docker is required" >&2; exit 3; }
ensure_image() {
target_image="$1"
docker image inspect "$target_image" >/dev/null 2>&1 && return
attempt=1
while [ "$attempt" -le 3 ]; do
if command -v timeout >/dev/null; then timeout 420 docker pull "$target_image" && return
else docker pull "$target_image" && return
fi
attempt=$((attempt + 1))
sleep $((attempt * 5))
done
echo "bounded image download failed after 3 attempts: $target_image" >&2
exit 3
}
[ "$browser_gate_mode" = "external" ] || ensure_image "$playwright_image"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT INT TERM
before="$work/source-before.txt"
after="$work/source-after.txt"
source_archive="$work/source.tar"
git -C "$repo" archive --format=tar --output="$source_archive" HEAD
sh "$repo/deploy/verify-source-manifest.sh" "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY" > "$before"
cat "$before"
if command -v dotnet >/dev/null; then
dotnet restore "$repo/Ludarium.slnx" --locked-mode
dotnet format "$repo/Ludarium.slnx" --no-restore --verify-no-changes
dotnet build "$repo/Ludarium.slnx" -c Release --no-restore -m:"$build_nodes" -p:UseSharedCompilation=false
LUDARIUM_RUN_CONTAINER_TESTS=1 dotnet test "$repo/Ludarium.slnx" -c Release --no-build --no-restore -m:"$build_nodes"
else
docker run --rm --network host --cpus "$gate_cpus" --memory "$gate_memory" \
--memory-swap "$gate_memory" --pids-limit 1024 --tmpfs /work:rw,nosuid,nodev,size=2g \
-v /var/run/docker.sock:/var/run/docker.sock -v "$source_archive:/source.tar:ro" -w /work \
-e LUDARIUM_RUN_CONTAINER_TESTS=1 -e LUDARIUM_GATE_BUILD_NODES="$build_nodes" \
"$dotnet_image" sh -lc \
'tar -xf /source.tar -C /work && dotnet restore Ludarium.slnx --locked-mode && dotnet format Ludarium.slnx --no-restore --verify-no-changes && dotnet build Ludarium.slnx -c Release --no-restore -m:"$LUDARIUM_GATE_BUILD_NODES" -p:UseSharedCompilation=false && dotnet test Ludarium.slnx -c Release --no-build --no-restore -m:"$LUDARIUM_GATE_BUILD_NODES"'
fi
# The emulator sidecar controllers are the last defence before a process launch inside an
# isolated player. They are stdlib-only, so this gate needs no dependency installation.
if command -v python3 >/dev/null; then
( cd "$repo" && python3 -m unittest discover -s tests/controllers -t tests/controllers )
else
docker run --rm --network none --cpus "$gate_cpus" --memory 512m \
-v "$repo:/repo:ro" -w /repo python:3.13-alpine@sha256:540c7d91f98ff6880174c40e99067bf5941eb54d818a7a5e094d188b196a934d \
python -m unittest discover -s tests/controllers -t tests/controllers
fi
mkdir -p "$data_root/app" "$data_root/cache" "$data_root/exports" "$data_root/postgres" "$data_root/backups"
chown -R "$PUID:$PGID" "$data_root/app" "$data_root/cache" "$data_root/exports"
export LUDARIUM_DATA_ROOT="$data_root" LUDARIUM_HTTP_PORT="$candidate_port" LUDARIUM_IMAGE="$image"
export LUDARIUM_CONTAINER_NAME="${LUDARIUM_CANDIDATE_CONTAINER_NAME:-${project}-Ludarium}"
docker compose -p "$project" -f "$compose" build --pull
image_digest="$(docker image inspect "$image" --format '{{.Id}}')"
docker compose -p "$project" -f "$compose" up -d --wait
app_id="$(docker compose -p "$project" -f "$compose" ps -q ludarium)"
[ -n "$app_id" ] || { echo "candidate app container was not created" >&2; exit 4; }
initial_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')"
[ "$initial_restart_count" = "0" ] || { echo "candidate started with an unexpected restart count: $initial_restart_count" >&2; exit 4; }
docker inspect "$app_id" --format '{{range .Config.Env}}{{println .}}{{end}}' |
grep -Fq 'GSS Encryption Mode=Disable' || {
echo "candidate database connection must disable unused local GSS negotiation" >&2
exit 4
}
for destination in /library/games /library/ps4 /library/ps5; do
docker inspect "$app_id" --format '{{range .Mounts}}{{println .Destination .RW}}{{end}}' | grep -F "$destination false" >/dev/null || {
echo "read-only mount proof failed for $destination" >&2; exit 5;
}
done
wget -qO- "http://127.0.0.1:$candidate_port/health/ready" >/dev/null
schema="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'select max(version) from schema_versions')"
expected_schema="$(sed -n 's/.*CurrentSchemaVersion = \([0-9][0-9]*\).*/\1/p' "$repo/src/Ludarium.Infrastructure/PostgresStore.cs")"
case "$expected_schema" in ''|*[!0-9]*) echo "could not read the declared schema version" >&2; exit 6;; esac
[ "$schema" = "$expected_schema" ] || { echo "expected schema $expected_schema, found $schema" >&2; exit 6; }
server_encoding="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'show server_encoding')"
[ "$server_encoding" = "UTF8" ] || { echo "expected UTF8 database encoding, found $server_encoding" >&2; exit 6; }
docker compose -p "$project" -f "$compose" exec -T ludarium locale -a | grep -Fx 'en_US.utf8' >/dev/null || {
echo "candidate runtime must provide the legacy production locale en_US.utf8" >&2
exit 6
}
for locale_setting in lc_messages lc_monetary lc_numeric lc_time; do
docker compose -p "$project" -f "$compose" exec -T ludarium \
psql -v ON_ERROR_STOP=1 -U ludarium -d ludarium \
-c "ALTER SYSTEM SET $locale_setting TO 'en_US.utf8'" >/dev/null
done
reload_result="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'select pg_reload_conf()')"
[ "$reload_result" = "t" ] || { echo "failed to load the production-shaped locale profile" >&2; exit 6; }
collation_fixture="ludarium_collation_fixture"
docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium --if-exists "$collation_fixture"
docker compose -p "$project" -f "$compose" exec -T ludarium \
createdb -U ludarium --template=template0 --locale=en_US.utf8 "$collation_fixture"
docker compose -p "$project" -f "$compose" exec -T ludarium psql -v ON_ERROR_STOP=1 -U ludarium -d "$collation_fixture" -c "
create table platform_definitions(id text,custom boolean not null,enabled boolean not null,version bigint not null,data jsonb not null,updated_at timestamptz not null);
insert into platform_definitions values
('game-gear',false,true,1,'{\"id\":\"game-gear\",\"custom\":false,\"enabled\":true,\"updatedAt\":\"2026-08-10T00:00:00Z\"}',timestamptz '2026-08-10T00:00:00Z'),
('game-gear',false,true,1,'{\"id\":\"game-gear\",\"custom\":false,\"enabled\":true,\"updatedAt\":\"2026-08-21T00:00:00Z\"}',timestamptz '2026-08-21T00:00:00Z');
create table indexed_fixture(id text primary key);
insert into indexed_fixture values('alpha'),('beta');" >/dev/null
fixture_index_before="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
"select relfilenode from pg_class where oid='indexed_fixture_pkey'::regclass")"
docker compose -p "$project" -f "$compose" exec -T ludarium psql -v ON_ERROR_STOP=1 -U ludarium -d postgres -c \
"update pg_database set datcollversion=null where datname='$collation_fixture'" >/dev/null
docker compose -p "$project" -f "$compose" exec -T -e POSTGRES_DB="$collation_fixture" ludarium \
/usr/local/bin/repair-postgres-collation.sh
fixture_rows="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
"select count(*) from platform_definitions where id='game-gear'")"
fixture_index_after="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
"select relfilenode from pg_class where oid='indexed_fixture_pkey'::regclass")"
fixture_version_current="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
"select datcollversion=pg_database_collation_actual_version(oid) from pg_database where datname=current_database()")"
docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium "$collation_fixture"
[ "$fixture_rows" = "1" ] || { echo "legacy collation fixture did not safely deduplicate built-in rows" >&2; exit 6; }
[ "$fixture_index_before" != "$fixture_index_after" ] || { echo "legacy collation fixture index was not rebuilt" >&2; exit 6; }
[ "$fixture_version_current" = "t" ] || { echo "legacy collation fixture version was not refreshed" >&2; exit 6; }
api="http://127.0.0.1:$candidate_port/api/v1"
auth="Authorization: Bearer $LUDARIUM_ADMIN_TOKEN"
libraries="$(curl -fsS -H "$auth" "$api/libraries")"
if [ "$(printf '%s' "$libraries" | jq 'length')" = "0" ]; then
create_library() {
curl -fsS -H "$auth" -H 'Content-Type: application/json' -d "$2" "$api/libraries" | jq -r '.id'
}
games_id="$(create_library games '{"name":"Games","path":"/library/games","kind":"Mixed","recursive":true,"hashPolicy":"OnDemand","inspectArchives":true,"maxConcurrency":1}')"
ps4_id="$(create_library ps4 '{"name":"PS4 Games","path":"/library/ps4","kind":"DiscImage","recursive":true,"hashPolicy":"OnDemand","inspectArchives":false,"maxConcurrency":1}')"
ps5_id="$(create_library ps5 '{"name":"PS5 Games","path":"/library/ps5","kind":"DiscImage","recursive":true,"hashPolicy":"OnDemand","inspectArchives":false,"maxConcurrency":1}')"
libraries="$(curl -fsS -H "$auth" "$api/libraries")"
fi
scan_ids=""
for library_id in $(printf '%s' "$libraries" | jq -r '.[].id'); do
curl -fsS -X POST -H "$auth" "$api/libraries/$library_id/verify" >/dev/null
scan_id="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d '{"mode":"Quick"}' "$api/libraries/$library_id/scans" | jq -r '.id')"
scan_ids="$scan_ids $scan_id"
done
deadline=$(( $(date +%s) + 1800 ))
while :; do
all_done=true
for scan_id in $scan_ids; do
state="$(curl -fsS -H "$auth" "$api/scans/$scan_id" | jq -r '.state')"
[ "$state" != "Failed" ] || { echo "candidate bootstrap scan failed: $scan_id" >&2; exit 6; }
[ "$state" = "Completed" ] || [ "$state" = "Cancelled" ] || all_done=false
done
[ "$all_done" = true ] && break
[ "$(date +%s)" -lt "$deadline" ] || { echo "candidate bootstrap scans timed out" >&2; exit 6; }
sleep 5
done
if [ "$browser_gate_mode" = "docker" ]; then
docker run --rm --network host --cpus "$browser_gate_cpus" --memory "$browser_gate_memory" \
--memory-swap "$browser_gate_memory" --pids-limit 1024 \
--tmpfs /work:rw,exec,nosuid,nodev,size=768m \
-e PLAYWRIGHT_BASE_URL="http://127.0.0.1:$candidate_port" \
-e LUDARIUM_ADMIN_TOKEN \
-v "$source_archive:/source.tar:ro" -w /work "$playwright_image" \
sh -lc 'tar -xf /source.tar -C /work --strip-components=2 src/Ludarium.Web && (npm ci --ignore-scripts || (sleep 5 && npm ci --ignore-scripts) || (sleep 15 && npm ci --ignore-scripts)) && node node_modules/vitest/vitest.mjs run --maxWorkers=4 && node node_modules/typescript/bin/tsc --noEmit && node node_modules/vite/bin/vite.js build && npm audit --audit-level=high && node e2e/workflows.mjs && node e2e/game-data-vault.mjs && node e2e/accessibility.mjs'
else
printf 'browser gate delegated: run the checked-in Playwright workflows against http://HOST:%s before promotion\n' "$candidate_port"
fi
post_browser_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')"
[ "$post_browser_restart_count" = "$initial_restart_count" ] || {
echo "candidate restarted unexpectedly during browser gates: $initial_restart_count -> $post_browser_restart_count" >&2
exit 6
}
docker compose -p "$project" -f "$compose" restart ludarium
docker compose -p "$project" -f "$compose" up -d --wait
wget -qO- "http://127.0.0.1:$candidate_port/health/ready" >/dev/null
server_encoding="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'show server_encoding')"
[ "$server_encoding" = "UTF8" ] || { echo "expected UTF8 database encoding after restart, found $server_encoding" >&2; exit 6; }
for locale_setting in lc_messages lc_monetary lc_numeric lc_time; do
configured_locale="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc "show $locale_setting")"
[ "$configured_locale" = "en_US.utf8" ] || {
echo "production-shaped locale $locale_setting did not survive restart: $configured_locale" >&2
exit 6
}
done
post_controlled_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')"
[ "$post_controlled_restart_count" = "$initial_restart_count" ] || {
echo "candidate restart count changed after controlled restart: $initial_restart_count -> $post_controlled_restart_count" >&2
exit 6
}
backup="$data_root/backups/candidate-schema${expected_schema}.dump"
docker compose -p "$project" -f "$compose" exec -T ludarium pg_dump -U ludarium -Fc ludarium > "$backup"
docker compose -p "$project" -f "$compose" exec -T ludarium createdb -U ludarium ludarium_restore
docker compose -p "$project" -f "$compose" exec -T ludarium pg_restore -U ludarium -d ludarium_restore --clean --if-exists < "$backup"
restored_schema="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium_restore -Atc 'select max(version) from schema_versions')"
docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium ludarium_restore
[ "$restored_schema" = "$expected_schema" ] || {
echo "restore schema verification failed: expected $expected_schema, found $restored_schema" >&2
exit 7
}
sh "$repo/deploy/verify-source-manifest.sh" "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY" > "$after"
cat "$after"
cmp -s "$before" "$after" || { echo "source manifest changed" >&2; exit 8; }
LUDARIUM_IMAGE="$image" LUDARIUM_RELEASE_VERSION="$release_version" LUDARIUM_RELEASE_TOOL_CACHE="$data_root/release-tools" sh "$repo/deploy/run-security-gates.sh"
if [ "$browser_gate_mode" = "docker" ]; then gate_state=passed; else gate_state=infrastructure-passed-browser-external-required; fi
printf 'candidate gate %s image=%s digest=%s port=%s schema=%s encoding=%s backup=%s\n' "$gate_state" "$image" "$image_digest" "$candidate_port" "$schema" "$server_encoding" "$backup"