Files
Genarrative/scripts/jenkins-preview-deployer.sh
T
kdletters 8ed0626627
Project CI / Frontend tests (push) Successful in 3m15s
Project CI / Repository checks (push) Successful in 2m12s
Project CI / Backend tests (push) Successful in 7m31s
Project CI / Native shell tests (push) Successful in 20m0s
恢复预览环境本地配置注入
恢复 API 与 worker 的 .env.local BuildKit secret 挂载

补齐 Jenkins 固定文件校验与预览文档

增加双配置文件构建门禁
2026-09-03 11:38:40 +08:00

555 lines
21 KiB
Bash

#!/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