256 lines
16 KiB
Bash
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"
|