Merge remote-tracking branch 'web/master' into feat/five_min_design
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const jenkinsfile = readFileSync(
|
||||
'jenkins/Jenkinsfile.preview-deployer',
|
||||
'utf8',
|
||||
);
|
||||
const deployer = readFileSync('scripts/jenkins-preview-deployer.sh', 'utf8');
|
||||
const resultWriter = readFileSync(
|
||||
'scripts/preview-deployment-status.mjs',
|
||||
'utf8',
|
||||
);
|
||||
const compose = readFileSync(
|
||||
'deploy/container/docker-compose.loadtest.yml',
|
||||
'utf8',
|
||||
);
|
||||
const webConfig = readFileSync(
|
||||
'apps/preview-deployer-web/vite.config.ts',
|
||||
'utf8',
|
||||
);
|
||||
const systemd = readFileSync(
|
||||
'deploy/systemd/genarrative-preview-deployer.service',
|
||||
'utf8',
|
||||
);
|
||||
const server = readFileSync(
|
||||
'server-rs/crates/preview-deployer-server/src/lib.rs',
|
||||
'utf8',
|
||||
);
|
||||
const nginx = readFileSync(
|
||||
'deploy/nginx/genarrative-preview-deployer-lan.conf',
|
||||
'utf8',
|
||||
);
|
||||
const jobConfig = readFileSync(
|
||||
'jenkins/preview-deployer-job-config.xml',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const failures = [];
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
"choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL']",
|
||||
'Jenkins 参数必须固定为 DEPLOY/STATUS/UNINSTALL。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
"string(name: 'SOURCE_BRANCH'",
|
||||
'Jenkins 必须暴露 SOURCE_BRANCH。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
"string(name: 'COMMIT_HASH'",
|
||||
'Jenkins 必须暴露 COMMIT_HASH。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
"string(name: 'DEPLOYMENT_ID'",
|
||||
'Jenkins 必须暴露 DEPLOYMENT_ID。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
"archiveArtifacts artifacts: 'preview-result.json'",
|
||||
'Jenkins 必须归档 preview-result.json。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
"writeFile file: 'preview-result.json'",
|
||||
'Jenkins 失败路径也必须生成 preview-result.json。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
'disableConcurrentBuilds()',
|
||||
'Jenkins Job 必须禁止并发构建。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
'COMMIT_HASH="${COMMIT_HASH:-}"',
|
||||
'Jenkins Checkout 阶段必须允许 COMMIT_HASH 未导出或为空。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
'bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh"',
|
||||
'Jenkins Checkout 阶段必须显式使用 bash 执行源码检出脚本。',
|
||||
);
|
||||
assertExcludes(
|
||||
jenkinsfile,
|
||||
'COMMIT_HASH="${COMMIT_HASH}"',
|
||||
'Jenkins Checkout 阶段不得在 set -u 下直接展开未导出的 COMMIT_HASH。',
|
||||
);
|
||||
assertIncludes(deployer, 'flock -x 9', '部署脚本必须使用跨进程独占锁。');
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'git check-ref-format --branch',
|
||||
'部署脚本必须校验分支格式。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'genarrative-preview-[0-9a-f]{16}',
|
||||
'卸载必须限制到预览 compose project 白名单。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'allocate_port webPort 8400 8499',
|
||||
'Web 端口必须从 8400-8499 分配。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'ports: !reset []',
|
||||
'预览 compose override 必须取消 SpacetimeDB 和 OTLP 宿主端口映射。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'spacetimedb:\n mem_limit: 2g\n ports: !reset []',
|
||||
'预览 SpacetimeDB 必须覆盖压测基线内存限制,避免模块首次加载时 OOM。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'external-generation-worker:\n build:',
|
||||
'预览 compose override 必须配置外部生成 worker。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'restart: on-failure',
|
||||
'预览外部生成 worker 必须在运行时身份初始化竞态后自动重启。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'^preview-[0-9a-f]{16}$',
|
||||
'部署 ID 必须固定为 preview- 加 16 位摘要。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'state.active === true && String(state[key]) === expected',
|
||||
'只有活动实例才能占用端口租约。',
|
||||
);
|
||||
assertIncludes(
|
||||
deployer,
|
||||
'--yes=remote,migrate,break-clients',
|
||||
'SpacetimeDB 预览发布必须显式确认受控容器网络目标,且不得默认删除实例数据。',
|
||||
);
|
||||
assertExcludes(
|
||||
deployer,
|
||||
'--delete-data',
|
||||
'预览部署不得使用 SpacetimeDB 删除数据参数。',
|
||||
);
|
||||
assertIncludes(
|
||||
jenkinsfile,
|
||||
"GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82'",
|
||||
'Jenkins 必须固定输出可供同事访问的内网 Web 主机地址。',
|
||||
);
|
||||
assertIncludes(webConfig, "?? '/build/'", 'SPA 默认 base 必须固定为 /build/。');
|
||||
assertIncludes(
|
||||
systemd,
|
||||
'User=jenkins',
|
||||
'控制服务必须使用 Jenkins 运行用户以访问受控构建状态。',
|
||||
);
|
||||
assertIncludes(
|
||||
nginx,
|
||||
'location ^~ /build/',
|
||||
'内网 Nginx 必须提供 /build/ 路由。',
|
||||
);
|
||||
assertIncludes(
|
||||
server,
|
||||
'.nest_service("/build/assets", ServeDir::new(static_dir.join("assets")))',
|
||||
'控制服务必须原生托管 /build 子路径,不能依赖 Nginx 隐式改写。',
|
||||
);
|
||||
assertIncludes(
|
||||
jobConfig,
|
||||
'<scriptPath>jenkins/Jenkinsfile.preview-deployer</scriptPath>',
|
||||
'Jenkins Job 必须从受保护 master 读取固定流水线。',
|
||||
);
|
||||
assertIncludes(
|
||||
jobConfig,
|
||||
'<hudson.model.ChoiceParameterDefinition>',
|
||||
'Jenkins Job 首次触发前必须已经声明 buildWithParameters 参数。',
|
||||
);
|
||||
assertIncludes(
|
||||
nginx,
|
||||
'location ^~ /api/preview-deployer/',
|
||||
'内网 Nginx 必须代理预览控制 API。',
|
||||
);
|
||||
assertIncludes(
|
||||
resultWriter,
|
||||
'schemaVersion: 1',
|
||||
'结果文件必须带版本化 schema。',
|
||||
);
|
||||
assertIncludes(resultWriter, 'healthStatus', '结果文件必须提供健康状态。');
|
||||
assertIncludes(resultWriter, 'webUrl', '结果文件必须提供 Web URL。');
|
||||
assertIncludes(
|
||||
resultWriter,
|
||||
'resolvedCommit',
|
||||
'结果文件必须提供 resolvedCommit。',
|
||||
);
|
||||
assertIncludes(
|
||||
resultWriter,
|
||||
'status: publicStatus(phase)',
|
||||
'结果文件必须提供小写 status 契约。',
|
||||
);
|
||||
assertIncludes(
|
||||
resultWriter,
|
||||
'health: publicHealth(healthStatus)',
|
||||
'结果文件必须提供小写 health 契约。',
|
||||
);
|
||||
assertIncludes(
|
||||
compose,
|
||||
'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]',
|
||||
'Nginx 容器健康检查必须验证 Web 首页。',
|
||||
);
|
||||
assertExcludes(
|
||||
compose,
|
||||
'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]',
|
||||
'Nginx 容器健康检查不能访问公开拒绝的 /healthz。',
|
||||
);
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures)
|
||||
console.error(`[check:preview-deployer] ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[check:preview-deployer] Jenkins 参数、隔离部署、卸载白名单、结果契约和 Nginx 健康检查通过。',
|
||||
);
|
||||
|
||||
function assertIncludes(content, expected, message) {
|
||||
if (!content.includes(expected)) failures.push(message);
|
||||
}
|
||||
|
||||
function assertExcludes(content, expected, message) {
|
||||
if (content.includes(expected)) failures.push(message);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
INSTALL_ROOT="${GENARRATIVE_PREVIEW_DEPLOYER_INSTALL_ROOT:-/opt/genarrative/preview-deployer}"
|
||||
STATE_ROOT="${GENARRATIVE_PREVIEW_DEPLOYER_STATE_ROOT:-/var/lib/genarrative/preview-deployer}"
|
||||
ENV_FILE="${GENARRATIVE_PREVIEW_DEPLOYER_ENV_FILE:-/etc/genarrative/preview-deployer.env}"
|
||||
UNIT_FILE="${GENARRATIVE_PREVIEW_DEPLOYER_UNIT_FILE:-/etc/systemd/system/genarrative-preview-deployer.service}"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "[preview-deployer-install] 必须以 root 执行安装。" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
echo "[preview-deployer-install] 缺少私密配置 ${ENV_FILE};请从 deploy/env/preview-deployer.env.example 创建并填入 Jenkins Token 和控制面口令。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
PREVIEW_DEPLOYER_WEB_BASE=/build/ npm run preview-deployer:web:build
|
||||
cargo build -p preview-deployer-server --release --manifest-path server-rs/Cargo.toml
|
||||
|
||||
install -d -o jenkins -g jenkins -m 0750 "${INSTALL_ROOT}" "${INSTALL_ROOT}/web"
|
||||
install -d -o jenkins -g jenkins -m 0700 "${STATE_ROOT}"
|
||||
install -o root -g root -m 0755 \
|
||||
server-rs/target/release/preview-deployer-server \
|
||||
"${INSTALL_ROOT}/preview-deployer-server"
|
||||
find "${INSTALL_ROOT}/web" -mindepth 1 -delete
|
||||
cp -a apps/preview-deployer-web/dist/. "${INSTALL_ROOT}/web/"
|
||||
chown -R root:root "${INSTALL_ROOT}/web"
|
||||
find "${INSTALL_ROOT}/web" -type d -exec chmod 0755 {} +
|
||||
find "${INSTALL_ROOT}/web" -type f -exec chmod 0644 {} +
|
||||
install -o root -g root -m 0644 \
|
||||
deploy/systemd/genarrative-preview-deployer.service "${UNIT_FILE}"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now genarrative-preview-deployer.service
|
||||
curl --fail --silent --show-error --max-time 5 \
|
||||
--header 'Host: 192.168.35.82' http://127.0.0.1:8410/healthz >/dev/null
|
||||
echo "[preview-deployer-install] 控制服务已安装;请将 deploy/nginx/genarrative-preview-deployer-lan.conf 嵌入内网默认 server 后执行 nginx -t && systemctl reload nginx。"
|
||||
@@ -0,0 +1,480 @@
|
||||
#!/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}"
|
||||
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
|
||||
|
||||
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} 已无可用端口。"
|
||||
}
|
||||
|
||||
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_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
|
||||
external-generation-worker:
|
||||
build:
|
||||
context: ${GENARRATIVE_PREVIEW_SOURCE_DIR}
|
||||
dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
|
||||
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 []
|
||||
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}`);
|
||||
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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,297 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const input = parseArgs(process.argv.slice(2));
|
||||
const state = readState(input.stateFile);
|
||||
const deploymentId = input.deploymentId || state?.deploymentId || '';
|
||||
const projectName = input.projectName || state?.projectName || '';
|
||||
const action = input.action || 'STATUS';
|
||||
const phaseOverride = input.phase || '';
|
||||
const containers = projectName ? inspectProjectContainers(projectName) : [];
|
||||
const serviceMap = new Map(
|
||||
containers.map((container) => [container.service, container]),
|
||||
);
|
||||
const webPort = numberOrNull(state?.webPort);
|
||||
const webHost = input.webHost || '127.0.0.1';
|
||||
const webUrl = webPort ? `http://${webHost}:${webPort}` : null;
|
||||
|
||||
const probes = {
|
||||
web: await probe(webPort ? `http://127.0.0.1:${webPort}/` : null),
|
||||
spacetime: probeSpacetimeContainer(serviceMap.get('spacetimedb')),
|
||||
};
|
||||
|
||||
const requiredServices = [
|
||||
'spacetimedb',
|
||||
'api-server',
|
||||
'external-generation-worker',
|
||||
'nginx',
|
||||
'otelcol',
|
||||
];
|
||||
const services = requiredServices.map((service) => {
|
||||
const container = serviceMap.get(service);
|
||||
return (
|
||||
container || {
|
||||
service,
|
||||
containerId: null,
|
||||
state: 'missing',
|
||||
health: 'missing',
|
||||
}
|
||||
);
|
||||
});
|
||||
const containersHealthy = services.every(
|
||||
(service) =>
|
||||
service.state === 'running' &&
|
||||
(service.health === 'healthy' || service.health === 'none'),
|
||||
);
|
||||
const probesHealthy = probes.web.ok && probes.spacetime.ok;
|
||||
const active = Boolean(state?.active);
|
||||
|
||||
let phase = phaseOverride || state?.phase || 'NOT_FOUND';
|
||||
let healthStatus = 'UNKNOWN';
|
||||
if (!state) {
|
||||
phase = phaseOverride || 'NOT_FOUND';
|
||||
healthStatus = phase === 'FAILED' ? 'UNHEALTHY' : 'NOT_FOUND';
|
||||
} else if (phase === 'UNINSTALLED' || action === 'UNINSTALL') {
|
||||
phase = 'UNINSTALLED';
|
||||
healthStatus = 'UNINSTALLED';
|
||||
} else if (phase === 'FAILED') {
|
||||
healthStatus = 'UNHEALTHY';
|
||||
} else if (active && containersHealthy && probesHealthy) {
|
||||
phase = 'RUNNING';
|
||||
healthStatus = 'HEALTHY';
|
||||
} else if (active || containers.length > 0) {
|
||||
phase = 'UNHEALTHY';
|
||||
healthStatus = 'UNHEALTHY';
|
||||
}
|
||||
|
||||
const result = {
|
||||
schemaVersion: 1,
|
||||
action,
|
||||
deploymentId,
|
||||
projectName: projectName || null,
|
||||
branch: state?.sourceBranch || input.sourceBranch || null,
|
||||
requestedCommit: state?.requestedCommit || null,
|
||||
resolvedCommit: state?.sourceCommit || null,
|
||||
status: publicStatus(phase),
|
||||
health: publicHealth(healthStatus),
|
||||
sourceBranch: state?.sourceBranch || input.sourceBranch || null,
|
||||
sourceCommit: state?.sourceCommit || null,
|
||||
phase,
|
||||
healthStatus,
|
||||
active: phase === 'RUNNING' || phase === 'UNHEALTHY',
|
||||
webPort,
|
||||
webUrl,
|
||||
ports: {
|
||||
web: webPort,
|
||||
spacetime: null,
|
||||
otlpGrpc: null,
|
||||
otlpHttp: null,
|
||||
},
|
||||
probes,
|
||||
services,
|
||||
message: input.message || defaultMessage(phase),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeJsonAtomic(input.output, result);
|
||||
if (input.descriptionFile) {
|
||||
const description = [
|
||||
deploymentId || 'unknown',
|
||||
result.sourceBranch || '-',
|
||||
phase,
|
||||
webUrl || '-',
|
||||
].join(' | ');
|
||||
writeFileSync(input.descriptionFile, `${description}\n`, 'utf8');
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
|
||||
function parseArgs(args) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const key = args[index];
|
||||
if (!key.startsWith('--')) {
|
||||
throw new Error(`未知参数: ${key}`);
|
||||
}
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
throw new Error(`参数缺少值: ${key}`);
|
||||
}
|
||||
parsed[toCamelCase(key.slice(2))] = value;
|
||||
index += 1;
|
||||
}
|
||||
if (!parsed.output) {
|
||||
throw new Error('必须提供 --output。');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toCamelCase(value) {
|
||||
return value.replace(/-([a-z])/gu, (_match, character) =>
|
||||
character.toUpperCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function readState(filePath) {
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function inspectProjectContainers(projectName) {
|
||||
let lines = '';
|
||||
try {
|
||||
lines = execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'ps',
|
||||
'-a',
|
||||
'--filter',
|
||||
`label=com.docker.compose.project=${projectName}`,
|
||||
'--format',
|
||||
'{{.ID}}|{{.Label "com.docker.compose.service"}}|{{.State}}',
|
||||
],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return lines
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [containerId, service, state] = line.split('|');
|
||||
return {
|
||||
service,
|
||||
containerId,
|
||||
state,
|
||||
health: inspectHealth(containerId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function inspectHealth(containerId) {
|
||||
try {
|
||||
const value = execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'inspect',
|
||||
'--format',
|
||||
'{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}',
|
||||
containerId,
|
||||
],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
).trim();
|
||||
return value || 'none';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
async function probe(url) {
|
||||
if (!url) {
|
||||
return { ok: false, status: null, elapsedMs: null };
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(3_000) });
|
||||
await response.body?.cancel();
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, status: null, elapsedMs: Date.now() - startedAt };
|
||||
}
|
||||
}
|
||||
|
||||
function probeSpacetimeContainer(container) {
|
||||
if (!container?.containerId || container.state !== 'running') {
|
||||
return { ok: false, status: null, elapsedMs: null };
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'exec',
|
||||
container.containerId,
|
||||
'spacetime',
|
||||
'server',
|
||||
'ping',
|
||||
'http://127.0.0.1:3101',
|
||||
],
|
||||
{ stdio: 'ignore', timeout: 3_000 },
|
||||
);
|
||||
return { ok: true, status: 200, elapsedMs: Date.now() - startedAt };
|
||||
} catch {
|
||||
return { ok: false, status: null, elapsedMs: Date.now() - startedAt };
|
||||
}
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) && number > 0 ? number : null;
|
||||
}
|
||||
|
||||
function defaultMessage(phase) {
|
||||
switch (phase) {
|
||||
case 'RUNNING':
|
||||
return '预览容器运行正常。';
|
||||
case 'UNHEALTHY':
|
||||
return '预览容器已启动,但健康检查未全部通过。';
|
||||
case 'UNINSTALLED':
|
||||
return '预览容器已卸载。';
|
||||
case 'NOT_FOUND':
|
||||
return '未找到对应的预览部署。';
|
||||
case 'FAILED':
|
||||
return '预览部署执行失败,请查看 Jenkins 构建日志。';
|
||||
default:
|
||||
return `预览部署状态:${phase}`;
|
||||
}
|
||||
}
|
||||
|
||||
function publicStatus(phase) {
|
||||
switch (phase) {
|
||||
case 'RUNNING':
|
||||
case 'UNHEALTHY':
|
||||
return 'running';
|
||||
case 'UNINSTALLED':
|
||||
case 'NOT_FOUND':
|
||||
return 'stopped';
|
||||
default:
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
function publicHealth(healthStatus) {
|
||||
switch (healthStatus) {
|
||||
case 'HEALTHY':
|
||||
return 'healthy';
|
||||
case 'UNHEALTHY':
|
||||
return 'unhealthy';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonAtomic(filePath, value) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const temporaryPath = `${absolutePath}.tmp-${process.pid}`;
|
||||
writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
renameSync(temporaryPath, absolutePath);
|
||||
}
|
||||
Reference in New Issue
Block a user