#!/usr/bin/env bash set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" cd "${REPO_ROOT}" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" SMOKE_ROOT="${BAT_API_LIVE_SMOKE_ROOT:-/tmp/bat-api-local-live-${timestamp}}" OUTPUT_DIR="${SMOKE_ROOT}/official" LOCALIZED_DIR="${SMOKE_ROOT}/localized" STATE_DIR="${SMOKE_ROOT}/state" REPORT_DIR="${SMOKE_ROOT}/report" FIXTURE_ROOT="${REPO_ROOT}/internal/api/testdata/release" BAT_BIN="${BAT_BIN:-${REPO_ROOT}/target/debug/bat}" BAT_API_BIN="${BAT_API_BIN:-${SMOKE_ROOT}/bat-api}" PORT="${BAT_API_LIVE_PORT:-18181}" BASE_URL="http://127.0.0.1:${PORT}" SOCKET_PATH="${STATE_DIR}/bat.sock" API_PID="" fail() { printf 'error: %s\n' "$*" >&2 exit 1 } require_command() { command -v "$1" >/dev/null 2>&1 || fail "required command is missing: $1" } require_command cargo require_command curl require_command go require_command jq require_command realpath [[ -d "${FIXTURE_ROOT}" ]] || fail "release fixture directory is missing: ${FIXTURE_ROOT}" [[ "${SMOKE_ROOT}" == /tmp/* ]] || fail "BAT_API_LIVE_SMOKE_ROOT must be under /tmp" [[ ! -e "${SMOKE_ROOT}" ]] || fail "smoke root already exists: ${SMOKE_ROOT}" [[ -x "${BAT_BIN}" || "${BAT_API_LIVE_SKIP_BUILD:-}" != "1" ]] || fail "bat binary is not executable: ${BAT_BIN}" mkdir -p "${OUTPUT_DIR}" "${LOCALIZED_DIR}" "${STATE_DIR}" "${REPORT_DIR}" if [[ "${BAT_API_LIVE_SKIP_BUILD:-}" != "1" ]]; then cargo build -p bat-infrastructure --bin bat GOCACHE="${GOCACHE:-/tmp/bat-go-cache}" go build -o "${BAT_API_BIN}" ./cmd/bat-api fi [[ -x "${BAT_BIN}" ]] || fail "bat binary is not executable: ${BAT_BIN}" [[ -x "${BAT_API_BIN}" ]] || fail "bat-api binary is not executable: ${BAT_API_BIN}" write_version_state() { local id="$1" local version_dir="$2" local snapshot_path="${version_dir}/official-sync-snapshot.json" local state_path="${OUTPUT_DIR}/official-version-state.json" local temp_path="${state_path}.tmp.$$" jq -n \ --arg id "${id}" \ --arg app_version "1.70.0" \ --arg bundle_version "s8tloc7lo3" \ --arg addressables_root "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture" \ --arg resource_root "${version_dir}" \ --arg snapshot_path "${snapshot_path}" \ '{ state_version: 1, current_completed_version: { id: $id, app_version: $app_version, bundle_version: $bundle_version, addressables_root: $addressables_root, resource_root: $resource_root, snapshot_path: $snapshot_path, staging_path: null, version_path: $resource_root, started_unix_seconds: null, completed_unix_seconds: 1 }, in_progress_version: null, previous_available_version: null, failed_versions: [], updated_unix_seconds: 1 }' >"${temp_path}" mv -f "${temp_path}" "${state_path}" } write_empty_version_state() { local state_path="${OUTPUT_DIR}/official-version-state.json" local temp_path="${state_path}.tmp.$$" jq -n '{ state_version: 1, current_completed_version: null, in_progress_version: null, previous_available_version: null, failed_versions: [], updated_unix_seconds: 1 }' >"${temp_path}" mv -f "${temp_path}" "${state_path}" } switch_current() { local id="$1" local link_path="${OUTPUT_DIR}/current" local temp_link="${OUTPUT_DIR}/.current.$$" ln -s "versions/${id}" "${temp_link}" mv -Tf "${temp_link}" "${link_path}" } stop_daemon() { if [[ -S "${SOCKET_PATH}" || -f "${STATE_DIR}/bat.pid" ]]; then "${BAT_BIN}" stop \ --state-dir "${STATE_DIR}" \ --json \ --no-progress \ --no-banner \ >"${REPORT_DIR}/daemon-stop.json" 2>"${REPORT_DIR}/daemon-stop.log" || true fi } cleanup() { if [[ -n "${API_PID}" ]] && kill -0 "${API_PID}" 2>/dev/null; then kill "${API_PID}" 2>/dev/null || true wait "${API_PID}" 2>/dev/null || true fi stop_daemon printf 'Smoke artifacts: %s\n' "${SMOKE_ROOT}" >&2 } trap cleanup EXIT mkdir -p "${OUTPUT_DIR}/versions/v-current" cp -a "${FIXTURE_ROOT}/." "${OUTPUT_DIR}/versions/v-current/" write_version_state "v-current" "${OUTPUT_DIR}/versions/v-current" switch_current "v-current" # The daemon is real and owns the live RPC socket, but this smoke must never # reach official endpoints. Its sync loop can fail and retry while the seeded # published release remains available to bat-api. LOCAL_CURL="${SMOKE_ROOT}/curl-network-blocked" printf '%s\n' \ '#!/usr/bin/env bash' \ 'printf "local live smoke blocks network access: curl %q\n" "$*" >&2' \ 'exit 7' >"${LOCAL_CURL}" chmod +x "${LOCAL_CURL}" printf '==> start Rust bat daemon\n' BAT_AUTO_DISCOVER=0 "${BAT_BIN}" \ --server-info-path "${FIXTURE_ROOT}/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" \ --connection-group Prod \ --app-version 1.70.0 \ --output "${OUTPUT_DIR}" \ --localized-output "${LOCALIZED_DIR}" \ --state-dir "${STATE_DIR}" \ --interval 1h \ --error-retry 1s \ --platforms Windows \ --curl "${LOCAL_CURL}" \ --daemon \ --json \ --no-progress \ --no-banner \ >"${REPORT_DIR}/daemon-start.json" 2>"${REPORT_DIR}/daemon-start.log" for _ in $(seq 1 100); do [[ -S "${SOCKET_PATH}" ]] && break sleep 0.1 done [[ -S "${SOCKET_PATH}" ]] || fail "Rust bat did not create ${SOCKET_PATH}; see ${REPORT_DIR}/daemon-start.log" printf '==> start bat-api on the same host\n' BAT_API_SKIP_ENV_FILE=1 "${BAT_API_BIN}" \ --listen "127.0.0.1:${PORT}" \ --public-base-url "${BASE_URL}" \ --state-dir "${STATE_DIR}" \ --socket "${SOCKET_PATH}" \ --server-info-file "${FIXTURE_ROOT}/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" \ --refresh-interval 100ms \ >"${REPORT_DIR}/bat-api.log" 2>&1 & API_PID="$!" wait_http_status() { local path="$1" local expected="$2" local response_file="$3" shift 3 local curl_args=("$@") local status="" for _ in $(seq 1 100); do status="$(curl -sS "${curl_args[@]}" -o "${response_file}" -w '%{http_code}' "${BASE_URL}${path}" || true)" if [[ "${status}" == "${expected}" ]]; then return 0 fi sleep 0.1 done fail "${path} expected HTTP ${expected}, got ${status}; response: $(tr '\n' ' ' <"${response_file}")" } wait_json() { local path="$1" local expression="$2" local response_file="$3" for _ in $(seq 1 100); do curl -sS "${BASE_URL}${path}" >"${response_file}" 2>/dev/null || true if jq -e "${expression}" "${response_file}" >/dev/null 2>&1; then return 0 fi sleep 0.1 done fail "${path} did not satisfy ${expression}; response: $(tr '\n' ' ' <"${response_file}")" } wait_json_status() { local path="$1" local expected="$2" local expression="$3" local response_file="$4" local status="" for _ in $(seq 1 100); do status="$(curl -sS -o "${response_file}" -w '%{http_code}' "${BASE_URL}${path}" || true)" if [[ "${status}" == "${expected}" ]] && jq -e "${expression}" "${response_file}" >/dev/null 2>&1; then return 0 fi sleep 0.1 done fail "${path} expected HTTP ${expected} and ${expression}, got ${status}; response: $(tr '\n' ' ' <"${response_file}")" } wait_json "/readyz" '.ready == true and (.resource_root | endswith("/versions/v-current"))' \ "${REPORT_DIR}/ready-initial.json" curl -fsS "${BASE_URL}/v1/bootstrap" >"${REPORT_DIR}/bootstrap-initial.json" jq -e ' .ready == true and .policy.pull_owner == "rust_bat" and .policy.resource_root_discovery == "bat_sock_rpc" and .resource.release.version_id == "v-current" ' "${REPORT_DIR}/bootstrap-initial.json" >/dev/null curl -fsS "${BASE_URL}/v1/launcher/bootstrap" >"${REPORT_DIR}/launcher-bootstrap-initial.json" jq -e ' .ready == true and .policy.package_update_manifest == false and .policy.downloads_launcher_package == false and .addressables_catalog_url_root == "http://127.0.0.1:'"${PORT}"'/prod-clientpatch.bluearchiveyostar.com/r93_fixture" ' "${REPORT_DIR}/launcher-bootstrap-initial.json" >/dev/null curl -fsS "${BASE_URL}/api-launcher-jp.yo-star.com/api/launcher/game/config/json" \ >"${REPORT_DIR}/launcher-config-json-initial.json" jq -e ' .code == 200 and .data.package_update_manifest == false and .data.scope == "resource_bootstrap_only" ' "${REPORT_DIR}/launcher-config-json-initial.json" >/dev/null curl -fsS "${BASE_URL}/v1/server-info" >"${REPORT_DIR}/server-info-initial.json" jq -e ' .ConnectionGroups[0].AddressablesCatalogUrlRoot == "http://127.0.0.1:'"${PORT}"'/prod-clientpatch.bluearchiveyostar.com/r93_fixture" ' "${REPORT_DIR}/server-info-initial.json" >/dev/null CDN_PATH="/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes" curl -fsS "${BASE_URL}${CDN_PATH}" >"${REPORT_DIR}/cdn-full.bytes" [[ "$(cat "${REPORT_DIR}/cdn-full.bytes")" == "TABLE_CATALOG_FIXTURE" ]] || fail "CDN full body mismatch" curl -fsSI "${BASE_URL}${CDN_PATH}" >"${REPORT_DIR}/cdn-head.headers" grep -Fqi 'content-length: 21' "${REPORT_DIR}/cdn-head.headers" || fail "CDN HEAD content length missing" grep -Fqi 'cache-control: public, max-age=31536000, immutable' "${REPORT_DIR}/cdn-head.headers" || fail "CDN cache header missing" curl -fsS -D "${REPORT_DIR}/cdn-range.headers" \ -H 'Range: bytes=0-3' \ -o "${REPORT_DIR}/cdn-range.bytes" \ "${BASE_URL}${CDN_PATH}" grep -Fq '206 Partial Content' "${REPORT_DIR}/cdn-range.headers" || fail "CDN range did not return 206" [[ "$(cat "${REPORT_DIR}/cdn-range.bytes")" == "TABL" ]] || fail "CDN range body mismatch" wait_http_status "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/missing.bin" "404" \ "${REPORT_DIR}/cdn-missing.response" wait_http_status \ "/prod-clientpatch.bluearchiveyostar.com/%2e%2e/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" \ "404" \ "${REPORT_DIR}/cdn-escape.response" \ --path-as-is printf '==> switch current release through the local published state\n' mkdir -p "${OUTPUT_DIR}/versions/v-next" cp -a "${FIXTURE_ROOT}/." "${OUTPUT_DIR}/versions/v-next/" write_version_state "v-next" "${OUTPUT_DIR}/versions/v-next" switch_current "v-next" wait_json "/readyz" '.ready == true and (.resource_root | endswith("/versions/v-next"))' \ "${REPORT_DIR}/ready-after-switch.json" curl -fsS "${BASE_URL}/v1/bootstrap" >"${REPORT_DIR}/bootstrap-after-switch.json" jq -e '.ready == true and .resource.release.version_id == "v-next"' \ "${REPORT_DIR}/bootstrap-after-switch.json" >/dev/null printf '==> publish a size-mismatched release and require not-ready\n' mkdir -p "${OUTPUT_DIR}/versions/v-broken" cp -a "${FIXTURE_ROOT}/." "${OUTPUT_DIR}/versions/v-broken/" printf 'intentional size mismatch\n' >"${OUTPUT_DIR}/versions/v-broken/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes" write_version_state "v-broken" "${OUTPUT_DIR}/versions/v-broken" switch_current "v-broken" wait_json_status "/readyz" "503" \ '.ready == false and .present_count == 1 and .missing_count == 1 and (.resource_root | endswith("/versions/v-broken"))' \ "${REPORT_DIR}/ready-size-mismatch.json" wait_http_status "${CDN_PATH}" "404" "${REPORT_DIR}/cdn-size-mismatch.response" printf '==> publish a manifest-missing release and require not-ready\n' mkdir -p "${OUTPUT_DIR}/versions/v-no-manifest" cp -a "${FIXTURE_ROOT}/." "${OUTPUT_DIR}/versions/v-no-manifest/" mv "${OUTPUT_DIR}/versions/v-no-manifest/official-download-manifest.json" \ "${OUTPUT_DIR}/versions/v-no-manifest/official-download-manifest.json.disabled" write_version_state "v-no-manifest" "${OUTPUT_DIR}/versions/v-no-manifest" switch_current "v-no-manifest" wait_json_status "/readyz" "503" \ '.ready == false and .resource_root == ""' \ "${REPORT_DIR}/ready-manifest-missing.json" printf '==> publish no-release state and require not-ready\n' write_empty_version_state wait_json_status "/readyz" "503" '.ready == false and .resource_root == ""' \ "${REPORT_DIR}/ready-no-release.json" switch_current "v-next" write_version_state "v-next" "${OUTPUT_DIR}/versions/v-next" wait_json "/readyz" '.ready == true and (.resource_root | endswith("/versions/v-next"))' \ "${REPORT_DIR}/ready-after-restore.json" printf '==> disconnect and reconnect bat.sock\n' stop_daemon for _ in $(seq 1 100); do [[ ! -S "${SOCKET_PATH}" ]] && break sleep 0.1 done wait_json "/healthz" '.rpc_available == false and .ready == false' \ "${REPORT_DIR}/health-rpc-disconnected.json" wait_http_status "/readyz" "503" "${REPORT_DIR}/ready-rpc-disconnected.json" BAT_AUTO_DISCOVER=0 "${BAT_BIN}" \ --server-info-path "${FIXTURE_ROOT}/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" \ --connection-group Prod \ --app-version 1.70.0 \ --output "${OUTPUT_DIR}" \ --localized-output "${LOCALIZED_DIR}" \ --state-dir "${STATE_DIR}" \ --interval 1h \ --error-retry 1s \ --platforms Windows \ --curl "${LOCAL_CURL}" \ --daemon \ --json \ --no-progress \ --no-banner \ >"${REPORT_DIR}/daemon-restart.json" 2>"${REPORT_DIR}/daemon-restart.log" for _ in $(seq 1 100); do [[ -S "${SOCKET_PATH}" ]] && break sleep 0.1 done [[ -S "${SOCKET_PATH}" ]] || fail "Rust bat did not recreate ${SOCKET_PATH}" wait_json "/readyz" '.ready == true and (.resource_root | endswith("/versions/v-next"))' \ "${REPORT_DIR}/ready-after-reconnect.json" curl -fsS "${BASE_URL}/healthz" >"${REPORT_DIR}/health-final.json" jq -e '.ok == true and .rpc_available == true and .ready == true' \ "${REPORT_DIR}/health-final.json" >/dev/null cat >"${REPORT_DIR}/SMOKE_REPORT.md" < \`/readyz\` 503 and no stale index - no published release -> \`/readyz\` 503 and empty index - RPC disconnect -> \`/healthz\` reports unavailable and \`/readyz\` 503 - daemon restart and release rediscovery All inputs are isolated under \`${SMOKE_ROOT}\`; no official network or client directory is used. EOF printf 'LOCAL_BAT_API_LIVE_SMOKE_OK\n' printf 'Smoke report: %s\n' "${REPORT_DIR}/SMOKE_REPORT.md"