diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 26416b4d..df4aa5a3 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -17,6 +17,7 @@ - `scripts/deploy/maintenance-status.sh` - `scripts/build-production-release.sh` - `scripts/jenkins-checkout-source.sh` +- `scripts/jenkins-server-provision.sh` - `scripts/deploy/production-web-deploy.sh` - `scripts/deploy/production-api-deploy.sh` - `scripts/deploy/production-stdb-publish.sh` @@ -97,6 +98,17 @@ API 发布阶段只使用上游 API 构建产物,不应回退到上游源码 commit 执行部署脚本;部署脚本应始终取 `SOURCE_BRANCH` 最新提交。否则全量流水线在修复部署脚本后仍可能按旧 `COMMIT_HASH` checkout,继续执行不认识新参数的旧版 `production-api-deploy.sh`。 +### 服务器配置流水线 + +`Genarrative-Server-Provision` 的 Jenkinsfile 只负责参数、节点路由与调用脚本;服务器配置主体逻辑放在 `scripts/jenkins-server-provision.sh`。不要再把数百行 Bash 内联进 Jenkins `sh ''' ... '''` 或 `bash -lc '...'`,否则 Jenkins/Groovy/sh/bash 多层转义会把 `\"`、`${...}`、sed 表达式等内容二次改写,容易在运行时出现 `syntax error near unexpected token '}'` 这类难定位错误。 + +该脚本负责安装构建依赖、同步 SpacetimeDB current 目录、安装 systemd/Nginx 配置、创建或保留 `/etc/genarrative/api-server.env`、维护模式配置以及首次服务启动前的 SpacetimeDB client token 初始化。修改后应至少执行: + +```bash +bash -n scripts/jenkins-server-provision.sh +git diff --check +``` + ## Nginx 规则 生产正式入口只保留必要路由: diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 7ccd9b4b..b809254f 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -98,532 +98,8 @@ BASH sh ''' bash <<'BASH' set -euo pipefail - - require_path() { - local path="$1" - if [[ ! -e "${path}" ]]; then - echo "[server-provision] 缺少必要文件: ${path}" >&2 - exit 1 - fi - } - - run_cmd() { - echo "+ $*" - if [[ "${DRY_RUN}" != "true" ]]; then - "$@" - fi - } - - install_file() { - local source="$1" - local target="$2" - local mode="$3" - echo "+ install -m ${mode} ${source} ${target}" - if [[ "${DRY_RUN}" != "true" ]]; then - install -m "${mode}" "${source}" "${target}" - fi - } - - install_build_dependencies() { - echo "[server-provision] 安装 Linux 构建依赖: clang, lld, pkg-config, OpenSSL headers" - if command -v apt-get >/dev/null 2>&1; then - run_cmd apt-get update - run_cmd apt-get install -y clang lld pkg-config libssl-dev ca-certificates - elif command -v dnf >/dev/null 2>&1; then - run_cmd dnf install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates - elif command -v yum >/dev/null 2>&1; then - run_cmd yum install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates - else - echo "[server-provision] 未找到 apt-get/dnf/yum,无法自动安装 clang/lld。请手动安装后重跑构建。" >&2 - exit 1 - fi - } - - install_sccache() { - for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do - if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then - export PATH="${tool_dir}:${PATH}" - fi - done - - if command -v sccache >/dev/null 2>&1; then - echo "[server-provision] sccache 已存在: $(command -v sccache)" - return - fi - - if [[ -x /root/.cargo/bin/sccache ]]; then - echo "[server-provision] sccache 已存在: /root/.cargo/bin/sccache" - return - fi - - echo "[server-provision] 未找到 sccache,准备通过 cargo install sccache 安装。" - if ! command -v cargo >/dev/null 2>&1; then - echo "[server-provision] 未找到 cargo,无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2 - exit 1 - fi - - if [[ "${DRY_RUN}" == "true" ]]; then - echo "+ cargo install sccache --locked" - return - fi - - cargo install sccache --locked - if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then - echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2 - exit 1 - fi - } - - sync_spacetime_install() { - local root_dir="$1" - local target_bin_dir="${root_dir}/bin/current" - local target_cli="${target_bin_dir}/spacetimedb-cli" - local target_standalone="${target_bin_dir}/spacetimedb-standalone" - local resolved_command="${SPACETIME_BIN_SOURCE}" - local install_dir="" - local root_bin="${root_dir}/bin" - local share_bin_dir="" - local version_dir="" - local parent_dir="" - - if [[ -x "${target_cli}" && -x "${target_standalone}" ]]; then - echo "[server-provision] SpacetimeDB current 目录已存在: ${target_bin_dir}" - return - fi - - echo "[server-provision] 同步 SpacetimeDB current 目录到 ${target_bin_dir}" - if [[ "${DRY_RUN}" == "true" ]]; then - echo "+ mkdir -p ${target_bin_dir}" - echo "+ copy spacetimedb-cli and spacetimedb-standalone into ${target_bin_dir}" - return - fi - - if command -v readlink >/dev/null 2>&1; then - resolved_command="$(readlink -f "${SPACETIME_BIN_SOURCE}" 2>/dev/null || echo "${SPACETIME_BIN_SOURCE}")" - fi - install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)" - mkdir -p "${root_bin}" - - for share_bin_dir in \ - "/usr/.local/share/spacetime/bin" \ - "/root/.local/share/spacetime/bin" \ - "${HOME:-}/.local/share/spacetime/bin"; do - if [[ -d "${share_bin_dir}" ]]; then - version_dir="$(find "${share_bin_dir}" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" - if [[ -n "${version_dir}" && -x "${version_dir}/spacetimedb-cli" && -x "${version_dir}/spacetimedb-standalone" ]]; then - echo "[server-provision] 同步 SpacetimeDB 安装: ${version_dir} -> ${target_bin_dir}" - rm -rf "${target_bin_dir}" - mkdir -p "${target_bin_dir}" - cp -a "${version_dir}/." "${target_bin_dir}/" - chmod +x "${target_cli}" "${target_standalone}" - chown -R spacetimedb:spacetimedb "${root_bin}" - return - fi - fi - done - - if [[ -d "${install_dir}/bin" ]]; then - echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}" - cp -a "${install_dir}/bin/." "${root_bin}/" - elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then - echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir} -> ${target_bin_dir}" - rm -rf "${target_bin_dir}" - mkdir -p "${target_bin_dir}" - cp -f "${install_dir}/spacetimedb-cli" "${target_cli}" - cp -f "${install_dir}/spacetimedb-standalone" "${target_standalone}" - chmod +x "${target_cli}" "${target_standalone}" - elif [[ -f "${resolved_command}" ]]; then - parent_dir="$(cd -- "${install_dir}/.." && pwd)" - if [[ -d "${parent_dir}/bin" && -x "${parent_dir}/bin/current/spacetimedb-cli" && -x "${parent_dir}/bin/current/spacetimedb-standalone" ]]; then - echo "[server-provision] 同步 SpacetimeDB 安装: ${parent_dir}/bin -> ${root_bin}" - cp -a "${parent_dir}/bin/." "${root_bin}/" - else - echo "[server-provision] 未能从 spacetime 命令路径推断完整 SpacetimeDB 安装目录: ${resolved_command}" >&2 - fi - fi - - if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then - echo "[server-provision] 同步 SpacetimeDB 安装后仍缺少 current 目录。" >&2 - echo "[server-provision] 需要同时存在: ${target_cli} 与 ${target_standalone}" >&2 - exit 1 - fi - - chown -R spacetimedb:spacetimedb "${root_bin}" - } - - is_spacetimedb_ready() { - local server_url="http://127.0.0.1:3101" - - if command -v curl >/dev/null 2>&1 && curl -fsS "${server_url}/v1/ping" >/dev/null 2>&1; then - return 0 - fi - - return 1 - } - - wait_for_spacetimedb_service() { - local deadline=$((SECONDS + 60)) - - if [[ "${DRY_RUN}" == "true" ]]; then - echo "+ wait for spacetimedb.service on http://127.0.0.1:3101" - return - fi - - while ((SECONDS < deadline)); do - if is_spacetimedb_ready; then - echo "[server-provision] spacetimedb.service 已就绪: http://127.0.0.1:3101" - return - fi - sleep 1 - done - - echo "[server-provision] 等待 spacetimedb.service 就绪超时。" >&2 - systemctl status spacetimedb.service --no-pager -l >&2 || true - journalctl -u spacetimedb.service --no-pager -n 80 >&2 || true - ss -ltnp >&2 || true - exit 1 - } - - read_env_value() { - local file="$1" - local key="$2" - local line value quote_char - quote_char='"' - - if [[ ! -f "${file}" ]]; then - return - fi - - while IFS= read -r line || [[ -n "${line}" ]]; do - if [[ "${line}" == "${key}="* ]]; then - value="${line#*=}" - value="$(printf "%s" "${value}" | tr -d "\r")" - if [[ ${#value} -ge 2 && "${value:0:1}" == "${quote_char}" && "${value: -1}" == "${quote_char}" ]]; then - value="${value:1:${#value}-2}" - fi - printf "%s" "${value}" - return - fi - done <"${file}" - } - - write_env_value() { - local file="$1" - local key="$2" - local value="$3" - local tmp updated line - - tmp="$(mktemp)" - updated="false" - while IFS= read -r line || [[ -n "${line}" ]]; do - if [[ "${line}" == "${key}="* ]]; then - if [[ "${updated}" != "true" ]]; then - printf "%s=%s\\n" "${key}" "${value}" >>"${tmp}" - updated="true" - fi - else - printf "%s\\n" "${line}" >>"${tmp}" - fi - done <"${file}" - if [[ "${updated}" != "true" ]]; then - printf "%s=%s\\n" "${key}" "${value}" >>"${tmp}" - fi - - cat "${tmp}" >"${file}" - rm -f "${tmp}" - chmod 0600 "${file}" - chown root:root "${file}" - } - - parse_json_string_field() { - local json="$1" - local key="$2" - - printf "%s" "${json}" | sed -n "s/.*\\\"${key}\\\"[[:space:]]*:[[:space:]]*\\\"\\([^\\\"]*\\)\\\".*/\\1/p" | head -n 1 - } - - ensure_spacetime_owner_client_token() { - local server_url="http://127.0.0.1:3101" - local cli_path="${SPACETIME_ROOT}/bin/current/spacetimedb-cli" - local token identity response login_output existing_token identity_preview - - if [[ "${DRY_RUN}" == "true" ]]; then - echo "+ ensure GENARRATIVE_SPACETIME_TOKEN in ${API_ENV_FILE}" - echo "+ generate SpacetimeDB client identity when token is missing" - echo "+ runuser -u spacetimedb -- ${cli_path} --root-dir ${SPACETIME_ROOT} login --token [REDACTED]" - return - fi - - if [[ ! -f "${API_ENV_FILE}" ]]; then - echo "[server-provision] 环境文件不存在,无法写入 GENARRATIVE_SPACETIME_TOKEN: ${API_ENV_FILE}" >&2 - exit 1 - fi - if [[ ! -x "${cli_path}" ]]; then - echo "[server-provision] SpacetimeDB CLI 不存在或不可执行: ${cli_path}" >&2 - exit 1 - fi - - existing_token="$(read_env_value "${API_ENV_FILE}" "GENARRATIVE_SPACETIME_TOKEN")" - if [[ -n "${existing_token}" ]]; then - token="${existing_token}" - echo "[server-provision] GENARRATIVE_SPACETIME_TOKEN 已存在,保留并同步 SpacetimeDB CLI 登录态。" - else - response="$(curl -fsS -X POST "${server_url}/v1/identity")" - identity="$(parse_json_string_field "${response}" "identity")" - identity="${identity:-$(parse_json_string_field "${response}" "Identity")}" - identity="${identity:-$(parse_json_string_field "${response}" "identity_hex")}" - identity="${identity:-$(parse_json_string_field "${response}" "identityHex")}" - token="$(parse_json_string_field "${response}" "token")" - token="${token:-$(parse_json_string_field "${response}" "Token")}" - if [[ -z "${identity}" || -z "${token}" ]]; then - echo "[server-provision] 生成 SpacetimeDB client identity 失败,响应缺少 identity/token。" >&2 - exit 1 - fi - - write_env_value "${API_ENV_FILE}" "GENARRATIVE_SPACETIME_TOKEN" "${token}" - identity_preview="${identity:0:12}" - echo "[server-provision] 已生成 SpacetimeDB client identity 并写入 GENARRATIVE_SPACETIME_TOKEN: ${identity_preview}..." - fi - - if ! login_output="$(runuser -u spacetimedb -- "${cli_path}" --root-dir "${SPACETIME_ROOT}" login --token "${token}" 2>&1)"; then - echo "[server-provision] 使用 GENARRATIVE_SPACETIME_TOKEN 登录 SpacetimeDB CLI 失败。" >&2 - printf "%s\\n" "${login_output}" | sed -E "s/[A-Za-z0-9_.=-]{24,}/[REDACTED]/g" >&2 - exit 1 - fi - echo "[server-provision] 已同步 SpacetimeDB CLI 登录态;后续首次 publish 将使用同一 client identity。" - } - - render_nginx_https_config() { - sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf - } - - render_nginx_development_http_config() { - sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative-dev-http.conf - } - - render_api_env_example() { - sed \ - -e "s|^GENARRATIVE_API_PORT=.*|GENARRATIVE_API_PORT=${API_PORT}|" \ - -e "s|^GENARRATIVE_SPACETIME_SERVER_URL=.*|GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101|" \ - deploy/env/api-server.env.example - } - - validate_nginx_tls() { - local cert_dir="/etc/letsencrypt/live/${SERVER_NAME}" - if [[ "${SERVER_NAME}" == "genarrative.example.com" ]]; then - echo "[server-provision] SERVER_NAME 仍是占位域名,拒绝写入 Nginx HTTPS 配置。请填写真实域名,或先设置 NGINX_CONFIG_MODE=none。" >&2 - exit 1 - fi - if [[ ! -f "${cert_dir}/fullchain.pem" || ! -f "${cert_dir}/privkey.pem" ]]; then - echo "[server-provision] 未找到 Nginx HTTPS 证书: ${cert_dir}/fullchain.pem 或 ${cert_dir}/privkey.pem" >&2 - echo "[server-provision] 请先完成证书申请,或首次初始化时设置 NGINX_CONFIG_MODE=none,避免写入无法通过 nginx -t 的配置。" >&2 - exit 1 - fi - } - - install_nginx_config_with_rollback() { - local config_target="/etc/nginx/conf.d/genarrative.conf" - local snippet_target="/etc/nginx/snippets/genarrative-maintenance.conf" - local config_source - local rendered_config rendered_snippet config_backup snippet_backup - local had_config="false" - local had_snippet="false" - - run_cmd mkdir -p /etc/nginx/snippets /etc/nginx/conf.d - if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then - config_source="deploy/nginx/genarrative.conf" - elif [[ "${NGINX_CONFIG_MODE}" == "development-http" ]]; then - config_source="deploy/nginx/genarrative-dev-http.conf" - else - echo "[server-provision] NGINX_CONFIG_MODE=${NGINX_CONFIG_MODE} 不需要安装 Nginx 配置。" - return - fi - - echo "+ render ${config_source} -> ${config_target}" - echo "+ install -m 0644 deploy/nginx/snippets/genarrative-maintenance.conf ${snippet_target}" - - if [[ "${DRY_RUN}" == "true" ]]; then - echo "+ nginx -t" - echo "+ nginx -s reload" - return - fi - - rendered_config="$(mktemp)" - rendered_snippet="$(mktemp)" - config_backup="$(mktemp)" - snippet_backup="$(mktemp)" - if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then - validate_nginx_tls - render_nginx_https_config >"${rendered_config}" - else - render_nginx_development_http_config >"${rendered_config}" - fi - cp deploy/nginx/snippets/genarrative-maintenance.conf "${rendered_snippet}" - - if [[ -f "${config_target}" ]]; then - cp -p "${config_target}" "${config_backup}" - had_config="true" - fi - if [[ -f "${snippet_target}" ]]; then - cp -p "${snippet_target}" "${snippet_backup}" - had_snippet="true" - fi - - install -m 0644 "${rendered_config}" "${config_target}" - install -m 0644 "${rendered_snippet}" "${snippet_target}" - - if ! nginx -t; then - echo "[server-provision] nginx -t 失败,恢复写入前的 Nginx 配置。" >&2 - if [[ "${had_config}" == "true" ]]; then - cp -p "${config_backup}" "${config_target}" - else - rm -f "${config_target}" - fi - if [[ "${had_snippet}" == "true" ]]; then - cp -p "${snippet_backup}" "${snippet_target}" - else - rm -f "${snippet_target}" - fi - rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" - exit 1 - fi - echo "+ nginx -s reload" - nginx -s reload - - rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" - } - - cleanup_placeholder_nginx_config() { - local config_target="/etc/nginx/conf.d/genarrative.conf" - local disabled_target - - if [[ ! -f "${config_target}" ]]; then - return - fi - - if ! grep -q "/etc/letsencrypt/live/genarrative.example.com/" "${config_target}"; then - return - fi - - disabled_target="${config_target}.disabled-placeholder-$(date +%Y%m%d%H%M%S)" - echo "[server-provision] 发现上一轮初始化留下的占位域名 Nginx 配置,禁用: ${config_target} -> ${disabled_target}" - if [[ "${DRY_RUN}" != "true" ]]; then - mv "${config_target}" "${disabled_target}" - if command -v nginx >/dev/null 2>&1; then - if ! nginx -t; then - echo "[server-provision] 占位配置已禁用,但 nginx -t 仍失败;请检查其他 Nginx 配置。" >&2 - else - echo "+ nginx -s reload" - nginx -s reload - fi - fi - fi - } - - escape_sed_replacement() { - printf "%s" "$1" | sed "s/[&|]/\\\\&/g" - } - - render_spacetimedb_service() { - local root_escaped - root_escaped="$(escape_sed_replacement "${SPACETIME_ROOT}")" - sed \ - -e "s|/stdb|${root_escaped}|g" \ - deploy/systemd/spacetimedb.service - } - - render_api_service() { - local current_escaped env_escaped - current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")" - env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")" - sed \ - -e "s|/opt/genarrative/current|${current_escaped}|g" \ - -e "s|/etc/genarrative/api-server.env|${env_escaped}|g" \ - deploy/systemd/genarrative-api.service - } - - require_path deploy/systemd/spacetimedb.service - require_path deploy/systemd/genarrative-api.service - require_path deploy/nginx/genarrative.conf - require_path deploy/nginx/genarrative-dev-http.conf - require_path deploy/nginx/snippets/genarrative-maintenance.conf - require_path deploy/env/api-server.env.example - require_path scripts/deploy/maintenance-on.sh - require_path scripts/deploy/maintenance-off.sh - require_path scripts/deploy/maintenance-status.sh - - echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)" - - run_cmd id - install_build_dependencies - install_sccache - run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth - - if ! id spacetimedb >/dev/null 2>&1; then - run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb - else - echo "[server-provision] 用户已存在: spacetimedb" - fi - - if ! id genarrative >/dev/null 2>&1; then - run_cmd useradd --system --home-dir /opt/genarrative --shell /usr/sbin/nologin genarrative - else - echo "[server-provision] 用户已存在: genarrative" - fi - - run_cmd chown -R spacetimedb:spacetimedb "${SPACETIME_ROOT}" - run_cmd chown -R genarrative:genarrative /opt/genarrative /var/lib/genarrative /srv/genarrative - - if [[ ! -x "${SPACETIME_BIN_SOURCE}" ]]; then - echo "[server-provision] spacetime CLI 不存在或不可执行: ${SPACETIME_BIN_SOURCE}" >&2 - exit 1 - fi - echo "+ install -m 0755 ${SPACETIME_BIN_SOURCE} ${SPACETIME_ROOT}/spacetime" - if [[ "${DRY_RUN}" != "true" ]]; then - install -m 0755 "${SPACETIME_BIN_SOURCE}" "${SPACETIME_ROOT}/spacetime" - chown spacetimedb:spacetimedb "${SPACETIME_ROOT}/spacetime" - fi - sync_spacetime_install "${SPACETIME_ROOT}" - - spacetimedb_service="$(mktemp)" - api_service="$(mktemp)" - render_spacetimedb_service >"${spacetimedb_service}" - render_api_service >"${api_service}" - install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644 - install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644 - rm -f "${spacetimedb_service}" "${api_service}" - - if [[ ! -f "${API_ENV_FILE}" ]]; then - echo "+ create ${API_ENV_FILE} from example" - if [[ "${DRY_RUN}" != "true" ]]; then - render_api_env_example >"${API_ENV_FILE}" - chmod 0600 "${API_ENV_FILE}" - chown root:root "${API_ENV_FILE}" - fi - else - echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}" - fi - - if [[ "${NGINX_CONFIG_MODE}" != "none" ]]; then - install_nginx_config_with_rollback - else - cleanup_placeholder_nginx_config - fi - - run_cmd systemctl daemon-reload - if [[ "${ENABLE_SERVICES}" == "true" ]]; then - run_cmd systemctl enable spacetimedb.service genarrative-api.service - run_cmd systemctl restart spacetimedb.service - wait_for_spacetimedb_service - ensure_spacetime_owner_client_token - if [[ -x "${CURRENT_LINK}/api-server" ]]; then - run_cmd systemctl restart genarrative-api.service - else - echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server 首次启动。后续 API deploy 会重启服务。" - fi - fi - - echo "[server-provision] 完成。若是首次初始化,请补齐 ${API_ENV_FILE} 的真实密钥后再启动 api-server。" + chmod +x scripts/jenkins-server-provision.sh + scripts/jenkins-server-provision.sh BASH ''' } diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh new file mode 100755 index 00000000..fd8f6df1 --- /dev/null +++ b/scripts/jenkins-server-provision.sh @@ -0,0 +1,528 @@ +#!/usr/bin/env bash +set -euo pipefail + +require_path() { + local path="$1" + if [[ ! -e "${path}" ]]; then + echo "[server-provision] 缺少必要文件: ${path}" >&2 + exit 1 + fi +} + +run_cmd() { + echo "+ $*" + if [[ "${DRY_RUN}" != "true" ]]; then + "$@" + fi +} + +install_file() { + local source="$1" + local target="$2" + local mode="$3" + echo "+ install -m ${mode} ${source} ${target}" + if [[ "${DRY_RUN}" != "true" ]]; then + install -m "${mode}" "${source}" "${target}" + fi +} + +install_build_dependencies() { + echo "[server-provision] 安装 Linux 构建依赖: clang, lld, pkg-config, OpenSSL headers" + if command -v apt-get >/dev/null 2>&1; then + run_cmd apt-get update + run_cmd apt-get install -y clang lld pkg-config libssl-dev ca-certificates + elif command -v dnf >/dev/null 2>&1; then + run_cmd dnf install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates + elif command -v yum >/dev/null 2>&1; then + run_cmd yum install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates + else + echo "[server-provision] 未找到 apt-get/dnf/yum,无法自动安装 clang/lld。请手动安装后重跑构建。" >&2 + exit 1 + fi +} + +install_sccache() { + for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do + if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then + export PATH="${tool_dir}:${PATH}" + fi + done + + if command -v sccache >/dev/null 2>&1; then + echo "[server-provision] sccache 已存在: $(command -v sccache)" + return + fi + + if [[ -x /root/.cargo/bin/sccache ]]; then + echo "[server-provision] sccache 已存在: /root/.cargo/bin/sccache" + return + fi + + echo "[server-provision] 未找到 sccache,准备通过 cargo install sccache 安装。" + if ! command -v cargo >/dev/null 2>&1; then + echo "[server-provision] 未找到 cargo,无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2 + exit 1 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ cargo install sccache --locked" + return + fi + + cargo install sccache --locked + if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then + echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2 + exit 1 + fi +} + +sync_spacetime_install() { + local root_dir="$1" + local target_bin_dir="${root_dir}/bin/current" + local target_cli="${target_bin_dir}/spacetimedb-cli" + local target_standalone="${target_bin_dir}/spacetimedb-standalone" + local resolved_command="${SPACETIME_BIN_SOURCE}" + local install_dir="" + local root_bin="${root_dir}/bin" + local share_bin_dir="" + local version_dir="" + local parent_dir="" + + if [[ -x "${target_cli}" && -x "${target_standalone}" ]]; then + echo "[server-provision] SpacetimeDB current 目录已存在: ${target_bin_dir}" + return + fi + + echo "[server-provision] 同步 SpacetimeDB current 目录到 ${target_bin_dir}" + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ mkdir -p ${target_bin_dir}" + echo "+ copy spacetimedb-cli and spacetimedb-standalone into ${target_bin_dir}" + return + fi + + if command -v readlink >/dev/null 2>&1; then + resolved_command="$(readlink -f "${SPACETIME_BIN_SOURCE}" 2>/dev/null || echo "${SPACETIME_BIN_SOURCE}")" + fi + install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)" + mkdir -p "${root_bin}" + + for share_bin_dir in \ + "/usr/.local/share/spacetime/bin" \ + "/root/.local/share/spacetime/bin" \ + "${HOME:-}/.local/share/spacetime/bin"; do + if [[ -d "${share_bin_dir}" ]]; then + version_dir="$(find "${share_bin_dir}" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" + if [[ -n "${version_dir}" && -x "${version_dir}/spacetimedb-cli" && -x "${version_dir}/spacetimedb-standalone" ]]; then + echo "[server-provision] 同步 SpacetimeDB 安装: ${version_dir} -> ${target_bin_dir}" + rm -rf "${target_bin_dir}" + mkdir -p "${target_bin_dir}" + cp -a "${version_dir}/." "${target_bin_dir}/" + chmod +x "${target_cli}" "${target_standalone}" + chown -R spacetimedb:spacetimedb "${root_bin}" + return + fi + fi + done + + if [[ -d "${install_dir}/bin" ]]; then + echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}" + cp -a "${install_dir}/bin/." "${root_bin}/" + elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then + echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir} -> ${target_bin_dir}" + rm -rf "${target_bin_dir}" + mkdir -p "${target_bin_dir}" + cp -f "${install_dir}/spacetimedb-cli" "${target_cli}" + cp -f "${install_dir}/spacetimedb-standalone" "${target_standalone}" + chmod +x "${target_cli}" "${target_standalone}" + elif [[ -f "${resolved_command}" ]]; then + parent_dir="$(cd -- "${install_dir}/.." && pwd)" + if [[ -d "${parent_dir}/bin" && -x "${parent_dir}/bin/current/spacetimedb-cli" && -x "${parent_dir}/bin/current/spacetimedb-standalone" ]]; then + echo "[server-provision] 同步 SpacetimeDB 安装: ${parent_dir}/bin -> ${root_bin}" + cp -a "${parent_dir}/bin/." "${root_bin}/" + else + echo "[server-provision] 未能从 spacetime 命令路径推断完整 SpacetimeDB 安装目录: ${resolved_command}" >&2 + fi + fi + + if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then + echo "[server-provision] 同步 SpacetimeDB 安装后仍缺少 current 目录。" >&2 + echo "[server-provision] 需要同时存在: ${target_cli} 与 ${target_standalone}" >&2 + exit 1 + fi + + chown -R spacetimedb:spacetimedb "${root_bin}" +} + +is_spacetimedb_ready() { + local server_url="http://127.0.0.1:3101" + + if command -v curl >/dev/null 2>&1 && curl -fsS "${server_url}/v1/ping" >/dev/null 2>&1; then + return 0 + fi + + return 1 +} + +wait_for_spacetimedb_service() { + local deadline=$((SECONDS + 60)) + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ wait for spacetimedb.service on http://127.0.0.1:3101" + return + fi + + while ((SECONDS < deadline)); do + if is_spacetimedb_ready; then + echo "[server-provision] spacetimedb.service 已就绪: http://127.0.0.1:3101" + return + fi + sleep 1 + done + + echo "[server-provision] 等待 spacetimedb.service 就绪超时。" >&2 + systemctl status spacetimedb.service --no-pager -l >&2 || true + journalctl -u spacetimedb.service --no-pager -n 80 >&2 || true + ss -ltnp >&2 || true + exit 1 +} + +read_env_value() { + local file="$1" + local key="$2" + local line value quote_char + quote_char='"' + + if [[ ! -f "${file}" ]]; then + return + fi + + while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ "${line}" == "${key}="* ]]; then + value="${line#*=}" + value="$(printf "%s" "${value}" | tr -d "\r")" + if [[ ${#value} -ge 2 && "${value:0:1}" == "${quote_char}" && "${value: -1}" == "${quote_char}" ]]; then + value="${value:1:${#value}-2}" + fi + printf "%s" "${value}" + return + fi + done <"${file}" +} + +write_env_value() { + local file="$1" + local key="$2" + local value="$3" + local tmp updated line + + tmp="$(mktemp)" + updated="false" + while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ "${line}" == "${key}="* ]]; then + if [[ "${updated}" != "true" ]]; then + printf "%s=%s\\n" "${key}" "${value}" >>"${tmp}" + updated="true" + fi + else + printf "%s\\n" "${line}" >>"${tmp}" + fi + done <"${file}" + if [[ "${updated}" != "true" ]]; then + printf "%s=%s\\n" "${key}" "${value}" >>"${tmp}" + fi + + cat "${tmp}" >"${file}" + rm -f "${tmp}" + chmod 0600 "${file}" + chown root:root "${file}" +} + +parse_json_string_field() { + local json="$1" + local key="$2" + + printf "%s" "${json}" | sed -n "s/.*\\\"${key}\\\"[[:space:]]*:[[:space:]]*\\\"\\([^\\\"]*\\)\\\".*/\\1/p" | head -n 1 +} + +ensure_spacetime_owner_client_token() { + local server_url="http://127.0.0.1:3101" + local cli_path="${SPACETIME_ROOT}/bin/current/spacetimedb-cli" + local token identity response login_output existing_token identity_preview + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ ensure GENARRATIVE_SPACETIME_TOKEN in ${API_ENV_FILE}" + echo "+ generate SpacetimeDB client identity when token is missing" + echo "+ runuser -u spacetimedb -- ${cli_path} --root-dir ${SPACETIME_ROOT} login --token [REDACTED]" + return + fi + + if [[ ! -f "${API_ENV_FILE}" ]]; then + echo "[server-provision] 环境文件不存在,无法写入 GENARRATIVE_SPACETIME_TOKEN: ${API_ENV_FILE}" >&2 + exit 1 + fi + if [[ ! -x "${cli_path}" ]]; then + echo "[server-provision] SpacetimeDB CLI 不存在或不可执行: ${cli_path}" >&2 + exit 1 + fi + + existing_token="$(read_env_value "${API_ENV_FILE}" "GENARRATIVE_SPACETIME_TOKEN")" + if [[ -n "${existing_token}" ]]; then + token="${existing_token}" + echo "[server-provision] GENARRATIVE_SPACETIME_TOKEN 已存在,保留并同步 SpacetimeDB CLI 登录态。" + else + response="$(curl -fsS -X POST "${server_url}/v1/identity")" + identity="$(parse_json_string_field "${response}" "identity")" + identity="${identity:-$(parse_json_string_field "${response}" "Identity")}" + identity="${identity:-$(parse_json_string_field "${response}" "identity_hex")}" + identity="${identity:-$(parse_json_string_field "${response}" "identityHex")}" + token="$(parse_json_string_field "${response}" "token")" + token="${token:-$(parse_json_string_field "${response}" "Token")}" + if [[ -z "${identity}" || -z "${token}" ]]; then + echo "[server-provision] 生成 SpacetimeDB client identity 失败,响应缺少 identity/token。" >&2 + exit 1 + fi + + write_env_value "${API_ENV_FILE}" "GENARRATIVE_SPACETIME_TOKEN" "${token}" + identity_preview="${identity:0:12}" + echo "[server-provision] 已生成 SpacetimeDB client identity 并写入 GENARRATIVE_SPACETIME_TOKEN: ${identity_preview}..." + fi + + if ! login_output="$(runuser -u spacetimedb -- "${cli_path}" --root-dir "${SPACETIME_ROOT}" login --token "${token}" 2>&1)"; then + echo "[server-provision] 使用 GENARRATIVE_SPACETIME_TOKEN 登录 SpacetimeDB CLI 失败。" >&2 + printf "%s\\n" "${login_output}" | sed -E "s/[A-Za-z0-9_.=-]{24,}/[REDACTED]/g" >&2 + exit 1 + fi + echo "[server-provision] 已同步 SpacetimeDB CLI 登录态;后续首次 publish 将使用同一 client identity。" +} + +render_nginx_https_config() { + sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf +} + +render_nginx_development_http_config() { + sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative-dev-http.conf +} + +render_api_env_example() { + sed \ + -e "s|^GENARRATIVE_API_PORT=.*|GENARRATIVE_API_PORT=${API_PORT}|" \ + -e "s|^GENARRATIVE_SPACETIME_SERVER_URL=.*|GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101|" \ + deploy/env/api-server.env.example +} + +validate_nginx_tls() { + local cert_dir="/etc/letsencrypt/live/${SERVER_NAME}" + if [[ "${SERVER_NAME}" == "genarrative.example.com" ]]; then + echo "[server-provision] SERVER_NAME 仍是占位域名,拒绝写入 Nginx HTTPS 配置。请填写真实域名,或先设置 NGINX_CONFIG_MODE=none。" >&2 + exit 1 + fi + if [[ ! -f "${cert_dir}/fullchain.pem" || ! -f "${cert_dir}/privkey.pem" ]]; then + echo "[server-provision] 未找到 Nginx HTTPS 证书: ${cert_dir}/fullchain.pem 或 ${cert_dir}/privkey.pem" >&2 + echo "[server-provision] 请先完成证书申请,或首次初始化时设置 NGINX_CONFIG_MODE=none,避免写入无法通过 nginx -t 的配置。" >&2 + exit 1 + fi +} + +install_nginx_config_with_rollback() { + local config_target="/etc/nginx/conf.d/genarrative.conf" + local snippet_target="/etc/nginx/snippets/genarrative-maintenance.conf" + local config_source + local rendered_config rendered_snippet config_backup snippet_backup + local had_config="false" + local had_snippet="false" + + run_cmd mkdir -p /etc/nginx/snippets /etc/nginx/conf.d + if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then + config_source="deploy/nginx/genarrative.conf" + elif [[ "${NGINX_CONFIG_MODE}" == "development-http" ]]; then + config_source="deploy/nginx/genarrative-dev-http.conf" + else + echo "[server-provision] NGINX_CONFIG_MODE=${NGINX_CONFIG_MODE} 不需要安装 Nginx 配置。" + return + fi + + echo "+ render ${config_source} -> ${config_target}" + echo "+ install -m 0644 deploy/nginx/snippets/genarrative-maintenance.conf ${snippet_target}" + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ nginx -t" + echo "+ nginx -s reload" + return + fi + + rendered_config="$(mktemp)" + rendered_snippet="$(mktemp)" + config_backup="$(mktemp)" + snippet_backup="$(mktemp)" + if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then + validate_nginx_tls + render_nginx_https_config >"${rendered_config}" + else + render_nginx_development_http_config >"${rendered_config}" + fi + cp deploy/nginx/snippets/genarrative-maintenance.conf "${rendered_snippet}" + + if [[ -f "${config_target}" ]]; then + cp -p "${config_target}" "${config_backup}" + had_config="true" + fi + if [[ -f "${snippet_target}" ]]; then + cp -p "${snippet_target}" "${snippet_backup}" + had_snippet="true" + fi + + install -m 0644 "${rendered_config}" "${config_target}" + install -m 0644 "${rendered_snippet}" "${snippet_target}" + + if ! nginx -t; then + echo "[server-provision] nginx -t 失败,恢复写入前的 Nginx 配置。" >&2 + if [[ "${had_config}" == "true" ]]; then + cp -p "${config_backup}" "${config_target}" + else + rm -f "${config_target}" + fi + if [[ "${had_snippet}" == "true" ]]; then + cp -p "${snippet_backup}" "${snippet_target}" + else + rm -f "${snippet_target}" + fi + rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" + exit 1 + fi + echo "+ nginx -s reload" + nginx -s reload + + rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" +} + +cleanup_placeholder_nginx_config() { + local config_target="/etc/nginx/conf.d/genarrative.conf" + local disabled_target + + if [[ ! -f "${config_target}" ]]; then + return + fi + + if ! grep -q "/etc/letsencrypt/live/genarrative.example.com/" "${config_target}"; then + return + fi + + disabled_target="${config_target}.disabled-placeholder-$(date +%Y%m%d%H%M%S)" + echo "[server-provision] 发现上一轮初始化留下的占位域名 Nginx 配置,禁用: ${config_target} -> ${disabled_target}" + if [[ "${DRY_RUN}" != "true" ]]; then + mv "${config_target}" "${disabled_target}" + if command -v nginx >/dev/null 2>&1; then + if ! nginx -t; then + echo "[server-provision] 占位配置已禁用,但 nginx -t 仍失败;请检查其他 Nginx 配置。" >&2 + else + echo "+ nginx -s reload" + nginx -s reload + fi + fi + fi +} + +escape_sed_replacement() { + printf "%s" "$1" | sed "s/[&|]/\\\\&/g" +} + +render_spacetimedb_service() { + local root_escaped + root_escaped="$(escape_sed_replacement "${SPACETIME_ROOT}")" + sed \ + -e "s|/stdb|${root_escaped}|g" \ + deploy/systemd/spacetimedb.service +} + +render_api_service() { + local current_escaped env_escaped + current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")" + env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")" + sed \ + -e "s|/opt/genarrative/current|${current_escaped}|g" \ + -e "s|/etc/genarrative/api-server.env|${env_escaped}|g" \ + deploy/systemd/genarrative-api.service +} + +require_path deploy/systemd/spacetimedb.service +require_path deploy/systemd/genarrative-api.service +require_path deploy/nginx/genarrative.conf +require_path deploy/nginx/genarrative-dev-http.conf +require_path deploy/nginx/snippets/genarrative-maintenance.conf +require_path deploy/env/api-server.env.example +require_path scripts/deploy/maintenance-on.sh +require_path scripts/deploy/maintenance-off.sh +require_path scripts/deploy/maintenance-status.sh + +echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)" + +run_cmd id +install_build_dependencies +install_sccache +run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth + +if ! id spacetimedb >/dev/null 2>&1; then + run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb +else + echo "[server-provision] 用户已存在: spacetimedb" +fi + +if ! id genarrative >/dev/null 2>&1; then + run_cmd useradd --system --home-dir /opt/genarrative --shell /usr/sbin/nologin genarrative +else + echo "[server-provision] 用户已存在: genarrative" +fi + +run_cmd chown -R spacetimedb:spacetimedb "${SPACETIME_ROOT}" +run_cmd chown -R genarrative:genarrative /opt/genarrative /var/lib/genarrative /srv/genarrative + +if [[ ! -x "${SPACETIME_BIN_SOURCE}" ]]; then + echo "[server-provision] spacetime CLI 不存在或不可执行: ${SPACETIME_BIN_SOURCE}" >&2 + exit 1 +fi +echo "+ install -m 0755 ${SPACETIME_BIN_SOURCE} ${SPACETIME_ROOT}/spacetime" +if [[ "${DRY_RUN}" != "true" ]]; then + install -m 0755 "${SPACETIME_BIN_SOURCE}" "${SPACETIME_ROOT}/spacetime" + chown spacetimedb:spacetimedb "${SPACETIME_ROOT}/spacetime" +fi +sync_spacetime_install "${SPACETIME_ROOT}" + +spacetimedb_service="$(mktemp)" +api_service="$(mktemp)" +render_spacetimedb_service >"${spacetimedb_service}" +render_api_service >"${api_service}" +install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644 +install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644 +rm -f "${spacetimedb_service}" "${api_service}" + +if [[ ! -f "${API_ENV_FILE}" ]]; then + echo "+ create ${API_ENV_FILE} from example" + if [[ "${DRY_RUN}" != "true" ]]; then + render_api_env_example >"${API_ENV_FILE}" + chmod 0600 "${API_ENV_FILE}" + chown root:root "${API_ENV_FILE}" + fi +else + echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}" +fi + +if [[ "${NGINX_CONFIG_MODE}" != "none" ]]; then + install_nginx_config_with_rollback +else + cleanup_placeholder_nginx_config +fi + +run_cmd systemctl daemon-reload +if [[ "${ENABLE_SERVICES}" == "true" ]]; then + run_cmd systemctl enable spacetimedb.service genarrative-api.service + run_cmd systemctl restart spacetimedb.service + wait_for_spacetimedb_service + ensure_spacetime_owner_client_token + if [[ -x "${CURRENT_LINK}/api-server" ]]; then + run_cmd systemctl restart genarrative-api.service + else + echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server 首次启动。后续 API deploy 会重启服务。" + fi +fi + +echo "[server-provision] 完成。若是首次初始化,请补齐 ${API_ENV_FILE} 的真实密钥后再启动 api-server。"