#!/usr/bin/env bash set -euo pipefail ACTION="${ACTION:-STATUS}" SOURCE_BRANCH="${SOURCE_BRANCH:-}" COMMIT_HASH="${COMMIT_HASH:-}" DEPLOYMENT_ID="${DEPLOYMENT_ID:-}" SOURCE_DIR="${SOURCE_DIR:-${WORKSPACE:-$(pwd)}/source}" RESULT_FILE="${RESULT_FILE:-${WORKSPACE:-$(pwd)}/preview-result.json}" DESCRIPTION_FILE="${DESCRIPTION_FILE:-${WORKSPACE:-$(pwd)}/.jenkins-preview-description}" STATE_ROOT="${GENARRATIVE_PREVIEW_STATE_ROOT:-/data/jenkins/preview-deployments}" PREVIEW_SECRETS_FILE="${GENARRATIVE_PREVIEW_SECRETS_FILE:-/data/jenkins/preview-secrets/.env.secrets.local}" PREVIEW_ENV_LOCAL_FILE="${GENARRATIVE_PREVIEW_ENV_LOCAL_FILE:-/data/jenkins/preview-secrets/.env.local}" WEB_HOST="${GENARRATIVE_PREVIEW_WEB_HOST:-}" LOCK_FILE="${GENARRATIVE_PREVIEW_LOCK_FILE:-${STATE_ROOT}/.lock}" STATE_DIR="" STATE_FILE="" PROJECT_NAME="" SCRIPT_ROOT="" SCRIPT_FAILED=1 PREVIEW_SECRETS_SHA256="" PREVIEW_ENV_LOCAL_SHA256="" fail() { echo "[preview-deployer] $*" >&2 exit 1 } validate_request() { case "${ACTION}" in DEPLOY|UNINSTALL|STATUS) ;; *) fail "ACTION 只支持 DEPLOY、UNINSTALL 或 STATUS: ${ACTION}" ;; esac if [[ -n "${SOURCE_BRANCH}" ]]; then [[ "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]] || fail "SOURCE_BRANCH 包含不允许的字符。" [[ "${SOURCE_BRANCH}" != /* && "${SOURCE_BRANCH}" != */ && "${SOURCE_BRANCH}" != *..* ]] || \ fail "SOURCE_BRANCH 不能以斜杠开头/结尾,也不能包含连续点号。" git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null 2>&1 || fail "SOURCE_BRANCH 不是合法 Git 分支名。" fi if [[ -n "${COMMIT_HASH}" && ! "${COMMIT_HASH}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then fail "COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash。" fi if [[ "${ACTION}" == "DEPLOY" && -z "${SOURCE_BRANCH}" ]]; then fail "DEPLOY 必须填写 SOURCE_BRANCH。" fi if [[ "${ACTION}" == "UNINSTALL" && -z "${SOURCE_BRANCH}" ]]; then fail "UNINSTALL 必须同时填写受控 SOURCE_BRANCH,以验证部署所有权。" fi if [[ "${ACTION}" != "DEPLOY" && -z "${SOURCE_BRANCH}" && -z "${DEPLOYMENT_ID}" ]]; then fail "UNINSTALL/STATUS 至少需要 SOURCE_BRANCH 或 DEPLOYMENT_ID。" fi if [[ -n "${DEPLOYMENT_ID}" && ! "${DEPLOYMENT_ID}" =~ ^preview-[0-9a-f]{16}$ ]]; then fail "DEPLOYMENT_ID 必须符合 preview- 加 16 位小写十六进制摘要。" fi } derive_deployment_id() { local digest [[ -n "${SOURCE_BRANCH}" ]] || return 0 digest="$(printf '%s' "${SOURCE_BRANCH}" | sha256sum)" digest="${digest%% *}" printf 'preview-%s\n' "${digest:0:16}" } resolve_identity() { local derived_id="" derived_id="$(derive_deployment_id)" if [[ -z "${DEPLOYMENT_ID}" ]]; then DEPLOYMENT_ID="${derived_id}" elif [[ -n "${derived_id}" && "${DEPLOYMENT_ID}" != "${derived_id}" ]]; then fail "DEPLOYMENT_ID 与 SOURCE_BRANCH 的稳定映射不一致,预期 ${derived_id}。" fi [[ -n "${DEPLOYMENT_ID}" ]] || fail "无法确定 DEPLOYMENT_ID。" PROJECT_NAME="genarrative-${DEPLOYMENT_ID}" STATE_DIR="${STATE_ROOT}/${DEPLOYMENT_ID}" STATE_FILE="${STATE_DIR}/state.json" } ensure_safe_state_root() { [[ "${STATE_ROOT}" == /* && "${STATE_ROOT}" != "/" ]] || fail "状态目录必须是非根目录绝对路径。" if [[ -L "${STATE_ROOT}" ]]; then fail "状态目录不能是符号链接: ${STATE_ROOT}" fi install -d -m 0700 "${STATE_ROOT}" if [[ -e "${STATE_DIR}" && ( -L "${STATE_DIR}" || ! -d "${STATE_DIR}" ) ]]; then fail "部署状态路径必须是普通目录且不能是符号链接: ${STATE_DIR}" fi install -d -m 0700 "${STATE_DIR}" } state_value() { local key="$1" [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || return 0 node -e ' const fs = require("node:fs"); const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8"))[process.argv[2]]; if (value !== undefined && value !== null) process.stdout.write(String(value)); ' "${STATE_FILE}" "${key}" } validate_existing_state() { local saved_id saved_project saved_branch [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || return 0 saved_id="$(state_value deploymentId)" saved_project="$(state_value projectName)" saved_branch="$(state_value sourceBranch)" [[ "${saved_id}" == "${DEPLOYMENT_ID}" ]] || fail "状态文件 deploymentId 不匹配。" [[ "${saved_project}" == "${PROJECT_NAME}" ]] || fail "状态文件 compose project 不匹配。" if [[ -n "${SOURCE_BRANCH}" && "${saved_branch}" != "${SOURCE_BRANCH}" ]]; then fail "部署实例属于分支 ${saved_branch},不能用 ${SOURCE_BRANCH} 操作。" fi if [[ -z "${SOURCE_BRANCH}" ]]; then SOURCE_BRANCH="${saved_branch}" fi } write_state() { local phase="$1" local active="$2" local source_commit="$3" local web_port="$4" local spacetime_port="$5" local otlp_grpc_port="$6" local otlp_http_port="$7" local temporary_file="${STATE_FILE}.tmp.$$" node - "${temporary_file}" "${DEPLOYMENT_ID}" "${PROJECT_NAME}" "${SOURCE_BRANCH}" \ "${COMMIT_HASH}" "${source_commit}" "${phase}" "${active}" "${web_port}" "${spacetime_port}" \ "${otlp_grpc_port}" "${otlp_http_port}" <<'NODE' const fs = require('node:fs'); const [file, deploymentId, projectName, sourceBranch, requestedCommit, sourceCommit, phase, active, webPort, spacetimePort, otlpGrpcPort, otlpHttpPort] = process.argv.slice(2); const number = (value) => value ? Number(value) : null; fs.writeFileSync(file, `${JSON.stringify({ schemaVersion: 1, deploymentId, projectName, sourceBranch, requestedCommit: requestedCommit || null, sourceCommit: sourceCommit || null, phase, active: active === 'true', webPort: number(webPort), spacetimePort: number(spacetimePort), otlpGrpcPort: number(otlpGrpcPort), otlpHttpPort: number(otlpHttpPort), updatedAt: new Date().toISOString(), }, null, 2)}\n`, {mode: 0o600}); NODE mv -f "${temporary_file}" "${STATE_FILE}" chmod 0600 "${STATE_FILE}" } render_result() { local phase="${1:-}" local message="${2:-}" local args=( "${SCRIPT_ROOT}/preview-deployment-status.mjs" --action "${ACTION}" --deployment-id "${DEPLOYMENT_ID}" --project-name "${PROJECT_NAME}" --source-branch "${SOURCE_BRANCH}" --state-file "${STATE_FILE}" --output "${RESULT_FILE}" --description-file "${DESCRIPTION_FILE}" --web-host "$(resolve_web_host)" ) if [[ -n "${phase}" ]]; then args+=(--phase "${phase}") fi if [[ -n "${message}" ]]; then args+=(--message "${message}") fi node "${args[@]}" } resolve_web_host() { local route_source if [[ -n "${WEB_HOST}" ]]; then printf '%s\n' "${WEB_HOST}" return fi route_source="$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([^ ]*\).*/\1/p' | head -n 1)" printf '%s\n' "${route_source:-127.0.0.1}" } on_exit() { local exit_code=$? if [[ "${SCRIPT_FAILED}" == "1" && -n "${STATE_FILE}" ]]; then set +e if [[ "${ACTION}" == "DEPLOY" && -f "${STATE_FILE}" ]]; then write_state "FAILED" "false" "$(state_value sourceCommit)" \ "$(state_value webPort)" "$(state_value spacetimePort)" \ "$(state_value otlpGrpcPort)" "$(state_value otlpHttpPort)" fi render_result "FAILED" "预览部署执行失败,请查看 Jenkins 构建日志。" fi exit "${exit_code}" } trap on_exit EXIT port_used_by_other_state() { local key="$1" local port="$2" node - "${STATE_ROOT}" "${DEPLOYMENT_ID}" "${key}" "${port}" <<'NODE' const fs = require('node:fs'); const path = require('node:path'); const [root, currentId, key, expected] = process.argv.slice(2); let used = false; for (const entry of fs.readdirSync(root, {withFileTypes: true})) { if (!entry.isDirectory() || entry.name === currentId) continue; try { const state = JSON.parse(fs.readFileSync(path.join(root, entry.name, 'state.json'), 'utf8')); if (state.active === true && String(state[key]) === expected) used = true; } catch {} } process.exit(used ? 0 : 1); NODE } port_is_listening() { local port="$1" ss -H -ltn "sport = :${port}" | grep -q . } allocate_port() { local key="$1" local start="$2" local end="$3" local preferred="${4:-}" local port if [[ -n "${preferred}" ]] && ! port_used_by_other_state "${key}" "${preferred}" && ! port_is_listening "${preferred}"; then printf '%s\n' "${preferred}" return fi for ((port = start; port <= end; port += 1)); do if ! port_used_by_other_state "${key}" "${port}" && ! port_is_listening "${port}"; then printf '%s\n' "${port}" return fi done fail "端口范围 ${start}-${end} 已无可用端口。" } validate_preview_input_file() { local file="$1" local label="$2" local file_dir file_mode file_owner dir_mode dir_owner canonical_file canonical_source [[ "${file}" == /* ]] || fail "${label}必须使用绝对路径。" [[ -f "${file}" && ! -L "${file}" && -r "${file}" ]] || \ fail "${label}必须是 Jenkins 可读的非符号链接普通文件: ${file}" file_dir="$(dirname "${file}")" [[ -d "${file_dir}" && ! -L "${file_dir}" ]] || \ fail "${label}所在目录必须是非符号链接目录: ${file_dir}" dir_mode="$(stat -c '%a' "${file_dir}")" [[ "${dir_mode}" == "700" ]] || fail "${label}所在目录权限必须是 0700: ${file_dir}" dir_owner="$(stat -c '%u' "${file_dir}")" [[ "${dir_owner}" == "${EUID}" ]] || fail "${label}所在目录必须归当前 Jenkins 执行用户所有。" file_mode="$(stat -c '%a' "${file}")" [[ "${file_mode}" == "600" ]] || fail "${label}权限必须是 0600: ${file}" file_owner="$(stat -c '%u' "${file}")" [[ "${file_owner}" == "${EUID}" ]] || fail "${label}必须归当前 Jenkins 执行用户所有。" canonical_file="$(realpath -e "${file}")" canonical_source="$(realpath -e "${SOURCE_DIR}")" [[ "${canonical_file}" != "${canonical_source}"/* ]] || \ fail "${label}不能位于目标分支源码上下文内。" } validate_preview_secrets_file() { validate_preview_input_file "${PREVIEW_SECRETS_FILE}" '预览 secrets 文件' validate_preview_input_file "${PREVIEW_ENV_LOCAL_FILE}" '预览 .env.local 文件' PREVIEW_SECRETS_SHA256="$(sha256sum "${PREVIEW_SECRETS_FILE}")" PREVIEW_SECRETS_SHA256="${PREVIEW_SECRETS_SHA256%% *}" [[ "${PREVIEW_SECRETS_SHA256}" =~ ^[0-9a-f]{64}$ ]] || fail "无法计算预览 secrets 文件摘要。" PREVIEW_ENV_LOCAL_SHA256="$(sha256sum "${PREVIEW_ENV_LOCAL_FILE}")" PREVIEW_ENV_LOCAL_SHA256="${PREVIEW_ENV_LOCAL_SHA256%% *}" [[ "${PREVIEW_ENV_LOCAL_SHA256}" =~ ^[0-9a-f]{64}$ ]] || fail "无法计算预览 .env.local 文件摘要。" } remove_project_resources() { local ids=() local images=() local volumes=() local networks=() [[ "${PROJECT_NAME}" =~ ^genarrative-preview-[0-9a-f]{16}$ ]] || fail "拒绝操作非白名单 compose project: ${PROJECT_NAME}" mapfile -t ids < <(docker ps -aq --filter "label=com.docker.compose.project=${PROJECT_NAME}") if [[ "${#ids[@]}" -gt 0 ]]; then docker rm -f "${ids[@]}" fi mapfile -t volumes < <(docker volume ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}") if [[ "${#volumes[@]}" -gt 0 ]]; then docker volume rm "${volumes[@]}" fi mapfile -t networks < <(docker network ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}") if [[ "${#networks[@]}" -gt 0 ]]; then docker network rm "${networks[@]}" fi mapfile -t images < <(docker image ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}") if [[ "${#images[@]}" -gt 0 ]]; then docker image rm "${images[@]}" fi } compose() { env \ COMPOSE_PROJECT_NAME="${PROJECT_NAME}" \ GENARRATIVE_PREVIEW_SOURCE_DIR="${SOURCE_DIR}" \ GENARRATIVE_PREVIEW_CONTROLLER_ROOT="${SCRIPT_ROOT}/.." \ GENARRATIVE_PREVIEW_SECRETS_FILE="${PREVIEW_SECRETS_FILE}" \ GENARRATIVE_PREVIEW_SECRETS_SHA256="${PREVIEW_SECRETS_SHA256}" \ GENARRATIVE_PREVIEW_ENV_LOCAL_FILE="${PREVIEW_ENV_LOCAL_FILE}" \ GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256="${PREVIEW_ENV_LOCAL_SHA256}" \ GENARRATIVE_CONTAINER_API_ENV_FILE="${STATE_DIR}/api-server.env" \ GENARRATIVE_CONTAINER_HTTP_PORT="${WEB_PORT}" \ GENARRATIVE_CONTAINER_SPACETIME_PORT="${SPACETIME_PORT}" \ GENARRATIVE_CONTAINER_OTLP_GRPC_PORT="${OTLP_GRPC_PORT}" \ GENARRATIVE_CONTAINER_OTLP_HTTP_PORT="${OTLP_HTTP_PORT}" \ docker compose \ -f "${SCRIPT_ROOT}/../deploy/container/docker-compose.loadtest.yml" \ -f "${STATE_DIR}/docker-compose.preview.yml" \ "$@" } write_compose_override() { local override_file="${STATE_DIR}/docker-compose.preview.yml" cat >"${override_file}" <<'YAML' services: api-server: build: context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile args: GENARRATIVE_PREVIEW_SECRETS_SHA256: ${GENARRATIVE_PREVIEW_SECRETS_SHA256} GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256: ${GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256} secrets: - source: preview_runtime_env target: genarrative_preview_secrets - source: preview_runtime_env_local target: genarrative_preview_env_local external-generation-worker: build: context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile args: GENARRATIVE_PREVIEW_SECRETS_SHA256: ${GENARRATIVE_PREVIEW_SECRETS_SHA256} GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256: ${GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256} secrets: - source: preview_runtime_env target: genarrative_preview_secrets - source: preview_runtime_env_local target: genarrative_preview_env_local restart: on-failure nginx: build: context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile spacetimedb: mem_limit: 2g ports: !reset [] otelcol: ports: !reset [] secrets: preview_runtime_env: file: ${GENARRATIVE_PREVIEW_SECRETS_FILE} preview_runtime_env_local: file: ${GENARRATIVE_PREVIEW_ENV_LOCAL_FILE} YAML chmod 0600 "${override_file}" } wait_for_url() { local url="$1" local attempts="${2:-60}" local attempt for ((attempt = 1; attempt <= attempts; attempt += 1)); do if curl --fail --silent --show-error --max-time 3 "${url}" >/dev/null 2>&1; then return 0 fi sleep 2 done return 1 } prepare_env() { local env_file="${STATE_DIR}/api-server.env" local internal_secret jwt_secret cp "${SOURCE_DIR}/deploy/container/api-server.env.example" "${env_file}" chmod 0600 "${env_file}" internal_secret="$(openssl rand -hex 32)" jwt_secret="$(openssl rand -hex 32)" node - "${env_file}" "${internal_secret}" "${jwt_secret}" <<'NODE' const fs = require('node:fs'); const [file, internalSecret, jwtSecret] = process.argv.slice(2); let content = fs.readFileSync(file, 'utf8'); content = content.replace(/^GENARRATIVE_INTERNAL_API_SECRET=.*$/mu, `GENARRATIVE_INTERNAL_API_SECRET=${internalSecret}`); content = content.replace(/^GENARRATIVE_JWT_SECRET=.*$/mu, `GENARRATIVE_JWT_SECRET=${jwtSecret}`); content += [ '', '# Jenkins 分支预览使用隔离数据库和预览专用认证,不继承生产账号或短信凭据。', 'GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true', 'SMS_AUTH_ENABLED=true', 'SMS_AUTH_PROVIDER=mock', 'SMS_AUTH_MOCK_VERIFY_CODE=123456', '', ].join('\n'); fs.writeFileSync(file, content, {encoding: 'utf8', mode: 0o600}); NODE unset internal_secret jwt_secret } configure_spacetime_identity() { local identity_payload spacetime_identity spacetime_token runtime_secret bootstrap_hash env_file env_file="${STATE_DIR}/api-server.env" identity_payload="$(curl --fail --silent --show-error --request POST \ --header 'Accept: application/json' --header 'Content-Type: application/json' \ "${SPACETIME_PUBLISH_URL}/v1/identity")" spacetime_identity="$(printf '%s' "${identity_payload}" | node -e ' let body=""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => { const data=JSON.parse(body); process.stdout.write(data.identity || data.Identity || data.identity_hex || data.identityHex || ""); }); ')" spacetime_token="$(printf '%s' "${identity_payload}" | node -e ' let body=""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => { const data=JSON.parse(body); process.stdout.write(data.token || data.Token || ""); }); ')" [[ -n "${spacetime_identity}" && -n "${spacetime_token}" ]] || fail "SpacetimeDB identity 响应缺少 identity 或 token。" runtime_secret="$(openssl rand -hex 32)" bootstrap_hash="$(printf '%s' "${runtime_secret}" | sha256sum)" bootstrap_hash="${bootstrap_hash%% *}" { printf '\nGENARRATIVE_SPACETIME_TOKEN=%s\n' "${spacetime_token}" printf 'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET=%s\n' "${runtime_secret}" } >>"${env_file}" printf '%s\n' "${bootstrap_hash}" >"${STATE_DIR}/bootstrap-secret.sha256" chmod 0600 "${env_file}" "${STATE_DIR}/bootstrap-secret.sha256" spacetime --config-path "${STATE_DIR}/spacetime-cli.toml" login --token "${spacetime_token}" >/dev/null chmod 0600 "${STATE_DIR}/spacetime-cli.toml" printf '[preview-deployer] SpacetimeDB identity: %s...\n' "${spacetime_identity:0:12}" unset identity_payload spacetime_token runtime_secret bootstrap_hash } deploy() { local saved_web source_commit [[ -d "${SOURCE_DIR}/.git" ]] || fail "缺少已经校验的源码 checkout: ${SOURCE_DIR}" [[ -f "${SOURCE_DIR}/deploy/container/docker-compose.loadtest.yml" ]] || fail "目标提交缺少容器 compose 文件。" [[ -f "${SOURCE_DIR}/deploy/container/api-server.env.example" ]] || fail "目标提交缺少容器 env 示例。" source_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify HEAD^{commit})" if [[ -n "${COMMIT_HASH}" ]]; then local requested_commit requested_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify "${COMMIT_HASH}^{commit}")" [[ "${source_commit}" == "${requested_commit}" ]] || fail "源码 checkout 与 COMMIT_HASH 不一致。" fi validate_preview_secrets_file saved_web="$(state_value webPort)" WEB_PORT="${saved_web:-8400}" SPACETIME_PORT="" OTLP_GRPC_PORT="" OTLP_HTTP_PORT="" prepare_env write_compose_override compose config --quiet compose build remove_project_resources WEB_PORT="$(allocate_port webPort 8400 8499 "${saved_web}")" write_state "BUILDING" "false" "${source_commit}" "${WEB_PORT}" "${SPACETIME_PORT}" "${OTLP_GRPC_PORT}" "${OTLP_HTTP_PORT}" compose up -d spacetimedb otelcol local spacetime_container_id spacetime_ip spacetime_container_id="$(compose ps -q spacetimedb)" [[ -n "${spacetime_container_id}" ]] || fail "未找到 SpacetimeDB 容器。" spacetime_ip="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${spacetime_container_id}")" [[ -n "${spacetime_ip}" ]] || fail "未取得 SpacetimeDB 容器地址。" SPACETIME_PUBLISH_URL="http://${spacetime_ip}:3101" wait_for_url "${SPACETIME_PUBLISH_URL}/v1/ping" || fail "SpacetimeDB 未在超时内就绪。" configure_spacetime_identity ( cd "${SOURCE_DIR}" # shellcheck disable=SC1091 source scripts/jenkins-prepare-toolchain-env.sh export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="$(cat "${STATE_DIR}/bootstrap-secret.sha256")" spacetime --config-path "${STATE_DIR}/spacetime-cli.toml" \ publish genarrative-loadtest \ --server "${SPACETIME_PUBLISH_URL}" \ --module-path server-rs/crates/spacetime-module \ --yes=remote,migrate,break-clients \ --build-options="--debug" \ --no-config ) compose up -d wait_for_url "http://127.0.0.1:${WEB_PORT}/" || fail "Web 未在超时内就绪。" write_state "RUNNING" "true" "${source_commit}" "${WEB_PORT}" "${SPACETIME_PORT}" "${OTLP_GRPC_PORT}" "${OTLP_HTTP_PORT}" local health_attempt for ((health_attempt = 1; health_attempt <= 30; health_attempt += 1)); do render_result if node -e ' const result = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); if (result.healthStatus !== "HEALTHY") process.exit(1); ' "${RESULT_FILE}"; then return fi sleep 2 done fail "容器健康检查未全部通过。" } uninstall() { [[ -f "${STATE_FILE}" && ! -L "${STATE_FILE}" ]] || fail "未找到可卸载的部署状态: ${DEPLOYMENT_ID}" local source_commit web_port spacetime_port otlp_grpc_port otlp_http_port source_commit="$(state_value sourceCommit)" web_port="$(state_value webPort)" spacetime_port="$(state_value spacetimePort)" otlp_grpc_port="$(state_value otlpGrpcPort)" otlp_http_port="$(state_value otlpHttpPort)" remove_project_resources write_state "UNINSTALLED" "false" "${source_commit}" "${web_port}" "${spacetime_port}" "${otlp_grpc_port}" "${otlp_http_port}" render_result "UNINSTALLED" } status() { if [[ ! -f "${STATE_FILE}" ]]; then render_result "NOT_FOUND" return fi render_result } validate_request SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" resolve_identity ensure_safe_state_root validate_existing_state exec 9>"${LOCK_FILE}" flock -x 9 case "${ACTION}" in DEPLOY) deploy ;; UNINSTALL) uninstall ;; STATUS) status ;; esac SCRIPT_FAILED=0 trap - EXIT