1279 lines
43 KiB
Bash
1279 lines
43 KiB
Bash
#!/usr/bin/env bash
|
||
|
||
set -euo pipefail
|
||
|
||
usage() {
|
||
cat <<EOF
|
||
用法:
|
||
npm run deploy:rust:remote
|
||
./scripts/deploy-rust-remote.sh --name 20260422-153000
|
||
|
||
说明:
|
||
1. 在仓库根目录创建 build/<当前时间>/ 作为 Ubuntu 发布包目录。
|
||
2. 使用 Vite 构建前端 release 到目标目录的 web/。
|
||
3. 构建 api-server 的 x86_64-unknown-linux-gnu release,并复制到目标目录。
|
||
4. 构建 spacetime-module 的 wasm32-unknown-unknown release,并复制 wasm 到目标目录。
|
||
5. 在目标目录生成 start.sh / stop.sh,用于目标服务器启动静态网站、SpacetimeDB、发布 wasm、启动 api-server。
|
||
|
||
常用参数:
|
||
--name <folder-name> 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS
|
||
--database <database> SpacetimeDB database,默认 ${DATABASE}
|
||
--api-port <port> api-server 端口,默认 ${API_PORT}
|
||
--web-port <port> 静态网站端口,默认 ${WEB_PORT}
|
||
--spacetime-port <port> SpacetimeDB 端口,默认 ${SPACETIME_PORT}
|
||
--ssh-key <path> 上传使用的 SSH 私钥,默认 ${SSH_KEY}
|
||
--remote <user@host> 上传目标 SSH 主机,默认 ${REMOTE_TARGET}
|
||
--remote-dir <path> 上传目标目录,默认 ${REMOTE_DIR}
|
||
--skip-upload 只生成本地发布包,不上传服务器
|
||
--skip-web-build 跳过 Vite 构建,仅用于调试
|
||
--skip-api-build 跳过 api-server 构建,仅用于调试
|
||
--skip-spacetime-build 跳过 wasm 构建,仅用于调试;此时必须同时传 --no-migration-bootstrap-secret
|
||
--no-migration-bootstrap-secret 构建不带迁移引导密钥的 spacetime-module wasm
|
||
|
||
目标服务器要求:
|
||
Ubuntu x86_64,已安装 node、spacetime CLI,并允许执行目标目录内的 start.sh / stop.sh。
|
||
如果在非 Linux 主机执行本脚本,需要本机 Rust 已配置 x86_64-unknown-linux-gnu 交叉编译工具链。
|
||
EOF
|
||
}
|
||
|
||
require_command() {
|
||
local command_name="$1"
|
||
|
||
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||
echo "[deploy:rust] 缺少命令: ${command_name}" >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
copy_required_file() {
|
||
local source_path="$1"
|
||
local target_path="$2"
|
||
local label="$3"
|
||
|
||
if [[ ! -f "${source_path}" ]]; then
|
||
echo "[deploy:rust] 缺少 ${label}: ${source_path}" >&2
|
||
exit 1
|
||
fi
|
||
|
||
cp "${source_path}" "${target_path}"
|
||
}
|
||
|
||
normalize_env_file() {
|
||
local env_file="$1"
|
||
local temp_file="${env_file}.tmp.$$"
|
||
|
||
if [[ ! -f "${env_file}" ]]; then
|
||
return
|
||
fi
|
||
|
||
# 发布环境文件可能由 Windows 编辑器保存,启动脚本只接受无 BOM、无 CRLF 的 KEY=value 文本。
|
||
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
|
||
cp "${temp_file}" "${env_file}"
|
||
}
|
||
|
||
write_env_override() {
|
||
local env_file="$1"
|
||
local key="$2"
|
||
local value="$3"
|
||
local temp_file="${env_file}.tmp.$$"
|
||
|
||
mkdir -p "$(dirname "${env_file}")"
|
||
if [[ -f "${env_file}" ]]; then
|
||
# 发布包参数是本次构建的权威值,必须覆盖从 Jenkins 工作区复制进来的旧 .env.local。
|
||
awk -v target_key="${key}" '
|
||
BEGIN {
|
||
pattern = "^[[:space:]]*(export[[:space:]]+)?" target_key "="
|
||
}
|
||
$0 !~ pattern {
|
||
print
|
||
}
|
||
' "${env_file}" >"${temp_file}"
|
||
else
|
||
: >"${temp_file}"
|
||
fi
|
||
|
||
printf "%s=%s\n" "${key}" "${value}" >>"${temp_file}"
|
||
cp "${temp_file}" "${env_file}"
|
||
}
|
||
|
||
copy_optional_file() {
|
||
local source_path="$1"
|
||
local target_path_a="$2"
|
||
local target_path_b="$3"
|
||
local label="$4"
|
||
|
||
if [[ ! -f "${source_path}" ]]; then
|
||
echo "[deploy:rust] 跳过未找到的可选文件 ${label}: ${source_path}"
|
||
return
|
||
fi
|
||
|
||
cp "${source_path}" "${target_path_a}"
|
||
cp "${source_path}" "${target_path_b}"
|
||
normalize_env_file "${target_path_a}"
|
||
normalize_env_file "${target_path_b}"
|
||
echo "[deploy:rust] 已复制 ${label} -> ${target_path_a} 与 ${target_path_b}"
|
||
}
|
||
|
||
normalize_local_path_for_bash() {
|
||
local value="$1"
|
||
|
||
if [[ "${value}" == "~"* ]]; then
|
||
local rest="${value:1}"
|
||
rest="${rest#\\}"
|
||
rest="${rest#/}"
|
||
rest="${rest//\\//}"
|
||
printf "%s/%s" "${HOME}" "${rest}"
|
||
return
|
||
fi
|
||
|
||
if [[ "${value}" =~ ^([A-Za-z]):\\(.*)$ ]]; then
|
||
local drive
|
||
drive="$(printf "%s" "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')"
|
||
local rest="${BASH_REMATCH[2]//\\//}"
|
||
printf "/%s/%s" "${drive}" "${rest}"
|
||
return
|
||
fi
|
||
|
||
printf "%s" "${value}"
|
||
}
|
||
|
||
remote_shell_quote() {
|
||
local value="$1"
|
||
printf "'%s'" "$(printf "%s" "${value}" | sed "s/'/'\\\\''/g")"
|
||
}
|
||
|
||
replace_placeholder_in_file() {
|
||
local file_path="$1"
|
||
local placeholder="$2"
|
||
local value="$3"
|
||
local escaped_value="${value//\\/\\\\}"
|
||
|
||
escaped_value="${escaped_value//&/\\&}"
|
||
escaped_value="${escaped_value//|/\\|}"
|
||
sed -i "s|${placeholder}|${escaped_value}|g" "${file_path}"
|
||
}
|
||
|
||
generate_migration_bootstrap_secret() {
|
||
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
|
||
}
|
||
|
||
prepare_migration_bootstrap_secret() {
|
||
case "${MIGRATION_BOOTSTRAP_SECRET_MODE}" in
|
||
auto)
|
||
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
|
||
;;
|
||
manual)
|
||
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
|
||
echo "[deploy:rust] 迁移引导密钥至少需要 16 个字符。" >&2
|
||
exit 1
|
||
fi
|
||
;;
|
||
disabled)
|
||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||
echo "[deploy:rust] 未启用迁移引导密钥。"
|
||
return
|
||
;;
|
||
*)
|
||
echo "[deploy:rust] 未知迁移引导密钥模式: ${MIGRATION_BOOTSTRAP_SECRET_MODE}" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
|
||
echo "[deploy:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"
|
||
}
|
||
|
||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
||
BUILD_ROOT="${REPO_ROOT}/build"
|
||
BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
|
||
DATABASE="xushi-p4wfr"
|
||
API_HOST="127.0.0.1"
|
||
API_PORT="8082"
|
||
WEB_HOST="127.0.0.1"
|
||
WEB_PORT="25001"
|
||
SPACETIME_HOST="127.0.0.1"
|
||
SPACETIME_PORT="3101"
|
||
SSH_KEY='~\.ssh\dsk.pem'
|
||
REMOTE_TARGET="ubuntu@82.157.175.59"
|
||
REMOTE_DIR="/home/ubuntu/genarrative"
|
||
UPLOAD_ENABLED=1
|
||
SKIP_WEB_BUILD=0
|
||
SKIP_API_BUILD=0
|
||
SKIP_SPACETIME_BUILD=0
|
||
BUILD_COMPLETED=0
|
||
MIGRATION_BOOTSTRAP_SECRET=""
|
||
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
-h|--help)
|
||
usage
|
||
exit 0
|
||
;;
|
||
--name)
|
||
BUILD_NAME="${2:?缺少 --name 的值}"
|
||
shift 2
|
||
;;
|
||
--database)
|
||
DATABASE="${2:?缺少 --database 的值}"
|
||
shift 2
|
||
;;
|
||
--api-host)
|
||
API_HOST="${2:?缺少 --api-host 的值}"
|
||
shift 2
|
||
;;
|
||
--api-port)
|
||
API_PORT="${2:?缺少 --api-port 的值}"
|
||
shift 2
|
||
;;
|
||
--web-host)
|
||
WEB_HOST="${2:?缺少 --web-host 的值}"
|
||
shift 2
|
||
;;
|
||
--web-port)
|
||
WEB_PORT="${2:?缺少 --web-port 的值}"
|
||
shift 2
|
||
;;
|
||
--spacetime-host)
|
||
SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}"
|
||
shift 2
|
||
;;
|
||
--spacetime-port)
|
||
SPACETIME_PORT="${2:?缺少 --spacetime-port 的值}"
|
||
shift 2
|
||
;;
|
||
--ssh-key)
|
||
SSH_KEY="${2:?缺少 --ssh-key 的值}"
|
||
shift 2
|
||
;;
|
||
--remote)
|
||
REMOTE_TARGET="${2:?缺少 --remote 的值}"
|
||
shift 2
|
||
;;
|
||
--remote-dir)
|
||
REMOTE_DIR="${2:?缺少 --remote-dir 的值}"
|
||
shift 2
|
||
;;
|
||
--skip-upload)
|
||
UPLOAD_ENABLED=0
|
||
shift
|
||
;;
|
||
--skip-web-build)
|
||
SKIP_WEB_BUILD=1
|
||
shift
|
||
;;
|
||
--skip-api-build)
|
||
SKIP_API_BUILD=1
|
||
shift
|
||
;;
|
||
--skip-spacetime-build)
|
||
SKIP_SPACETIME_BUILD=1
|
||
shift
|
||
;;
|
||
--migration-bootstrap-secret)
|
||
MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}"
|
||
MIGRATION_BOOTSTRAP_SECRET_MODE="manual"
|
||
shift 2
|
||
;;
|
||
--no-migration-bootstrap-secret)
|
||
MIGRATION_BOOTSTRAP_SECRET=""
|
||
MIGRATION_BOOTSTRAP_SECRET_MODE="disabled"
|
||
shift
|
||
;;
|
||
*)
|
||
echo "[deploy:rust] 未知参数: $1" >&2
|
||
usage >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then
|
||
echo "[deploy:rust] --name 只能包含数字、字母、点、下划线和短横线。" >&2
|
||
exit 1
|
||
fi
|
||
|
||
if [[ ! "${DATABASE}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||
echo "[deploy:rust] --database 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${DATABASE}" >&2
|
||
exit 1
|
||
fi
|
||
|
||
echo "[deploy:rust] SpacetimeDB 发布数据库: ${DATABASE}"
|
||
|
||
if [[ "${SKIP_SPACETIME_BUILD}" -eq 1 && "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
|
||
echo "[deploy:rust] --skip-spacetime-build 无法把迁移引导密钥注入 wasm。" >&2
|
||
echo "[deploy:rust] 请移除 --skip-spacetime-build,或同时传 --no-migration-bootstrap-secret。" >&2
|
||
exit 1
|
||
fi
|
||
|
||
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
|
||
WEB_DIR="${TARGET_DIR}/web"
|
||
ADMIN_WEB_DIR="${WEB_DIR}/admin"
|
||
API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server"
|
||
WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
|
||
|
||
cleanup_partial_build() {
|
||
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${TARGET_DIR:-}" && -d "${TARGET_DIR}" ]]; then
|
||
echo "[deploy:rust] 清理未完成发布包: ${TARGET_DIR}" >&2
|
||
rm -rf "${TARGET_DIR}"
|
||
fi
|
||
}
|
||
|
||
trap cleanup_partial_build EXIT
|
||
|
||
if [[ -e "${TARGET_DIR}" ]]; then
|
||
echo "[deploy:rust] 目标目录已存在: ${TARGET_DIR}" >&2
|
||
exit 1
|
||
fi
|
||
|
||
require_command node
|
||
require_command cargo
|
||
|
||
prepare_migration_bootstrap_secret
|
||
|
||
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||
require_command npm
|
||
fi
|
||
|
||
NORMALIZED_SSH_KEY="$(normalize_local_path_for_bash "${SSH_KEY}")"
|
||
|
||
if [[ "${UPLOAD_ENABLED}" -eq 1 ]]; then
|
||
require_command ssh
|
||
require_command scp
|
||
|
||
if [[ ! -f "${NORMALIZED_SSH_KEY}" ]]; then
|
||
echo "[deploy:rust] SSH 私钥不存在: ${SSH_KEY}" >&2
|
||
echo "[deploy:rust] Git Bash 解析路径: ${NORMALIZED_SSH_KEY}" >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
mkdir -p "${WEB_DIR}"
|
||
|
||
echo "[deploy:rust] 发布包目录: ${TARGET_DIR}"
|
||
|
||
copy_optional_file "${REPO_ROOT}/.env" "${TARGET_DIR}/.env" "${WEB_DIR}/.env" ".env"
|
||
copy_optional_file "${REPO_ROOT}/.env.local" "${TARGET_DIR}/.env.local" "${WEB_DIR}/.env.local" ".env.local"
|
||
write_env_override "${TARGET_DIR}/.env.local" "GENARRATIVE_SPACETIME_DATABASE" "${DATABASE}"
|
||
write_env_override "${WEB_DIR}/.env.local" "GENARRATIVE_SPACETIME_DATABASE" "${DATABASE}"
|
||
|
||
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||
echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}"
|
||
(
|
||
cd "${REPO_ROOT}"
|
||
node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir
|
||
)
|
||
|
||
echo "[deploy:rust] 构建后台 Vite release -> ${ADMIN_WEB_DIR}"
|
||
(
|
||
cd "${REPO_ROOT}"
|
||
MSYS2_ARG_CONV_EXCL="--base=" node scripts/admin-web-build.mjs build --base=/admin/ --outDir "${ADMIN_WEB_DIR}" --emptyOutDir
|
||
)
|
||
fi
|
||
|
||
if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then
|
||
echo "[deploy:rust] 构建 api-server -> x86_64-unknown-linux-gnu"
|
||
(
|
||
cd "${SERVER_RS_DIR}"
|
||
cargo build \
|
||
-p api-server \
|
||
--release \
|
||
--target x86_64-unknown-linux-gnu \
|
||
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
|
||
)
|
||
fi
|
||
|
||
copy_required_file "${API_BINARY_SOURCE}" "${TARGET_DIR}/api-server" "api-server release binary"
|
||
chmod +x "${TARGET_DIR}/api-server"
|
||
|
||
if [[ "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then
|
||
echo "[deploy:rust] 构建 spacetime-module -> wasm32-unknown-unknown"
|
||
(
|
||
cd "${SERVER_RS_DIR}"
|
||
cargo build \
|
||
-p spacetime-module \
|
||
--release \
|
||
--target wasm32-unknown-unknown \
|
||
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
|
||
)
|
||
fi
|
||
|
||
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
|
||
|
||
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
|
||
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${TARGET_DIR}/migration-bootstrap-secret.txt"
|
||
chmod 600 "${TARGET_DIR}/migration-bootstrap-secret.txt"
|
||
fi
|
||
|
||
mkdir -p "${TARGET_DIR}/scripts"
|
||
for migration_script in \
|
||
spacetime-migration-common.mjs \
|
||
spacetime-export-migration-json.mjs \
|
||
spacetime-import-migration-json.mjs \
|
||
spacetime-authorize-migration-operator.mjs \
|
||
spacetime-revoke-migration-operator.mjs; do
|
||
copy_required_file \
|
||
"${SCRIPT_DIR}/${migration_script}" \
|
||
"${TARGET_DIR}/scripts/${migration_script}" \
|
||
"SpacetimeDB 迁移脚本 ${migration_script}"
|
||
done
|
||
|
||
cat >"${TARGET_DIR}/web-server.mjs" <<'WEB_SERVER'
|
||
import http from 'node:http';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import {fileURLToPath} from 'node:url';
|
||
|
||
const releaseDir = path.dirname(fileURLToPath(import.meta.url));
|
||
const webRoot = path.join(releaseDir, 'web');
|
||
const adminWebRoot = path.join(webRoot, 'admin');
|
||
const webHost = process.env.GENARRATIVE_WEB_HOST || '127.0.0.1';
|
||
const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000');
|
||
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
|
||
const indexPath = path.join(webRoot, 'index.html');
|
||
const adminIndexPath = path.join(adminWebRoot, 'index.html');
|
||
const proxyPrefixes = [
|
||
'/admin/api',
|
||
'/api/',
|
||
'/api',
|
||
'/generated-character-drafts',
|
||
'/generated-characters',
|
||
'/generated-animations',
|
||
'/generated-custom-world-scenes',
|
||
'/generated-custom-world-covers',
|
||
'/generated-qwen-sprites',
|
||
'/healthz',
|
||
];
|
||
|
||
function isProxyPath(pathname) {
|
||
return proxyPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||
}
|
||
|
||
function contentTypeFor(filePath) {
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
const typeMap = {
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.ico': 'image/x-icon',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.png': 'image/png',
|
||
'.svg': 'image/svg+xml',
|
||
'.txt': 'text/plain; charset=utf-8',
|
||
'.webp': 'image/webp',
|
||
};
|
||
return typeMap[ext] || 'application/octet-stream';
|
||
}
|
||
|
||
function sendFile(response, filePath) {
|
||
fs.createReadStream(filePath)
|
||
.on('error', () => {
|
||
response.writeHead(500, {'content-type': 'text/plain; charset=utf-8'});
|
||
response.end('failed to read static file');
|
||
})
|
||
.pipe(response);
|
||
}
|
||
|
||
function serveStaticFromRoot(response, pathname, rootDir, fallbackIndexPath) {
|
||
const decodedPath = decodeURIComponent(pathname);
|
||
const relativePath = decodedPath === '/' ? '/index.html' : decodedPath;
|
||
const filePath = path.normalize(path.join(rootDir, relativePath));
|
||
const safeRelativePath = path.relative(rootDir, filePath);
|
||
|
||
if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) {
|
||
response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'});
|
||
response.end('forbidden');
|
||
return;
|
||
}
|
||
|
||
const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||
? filePath
|
||
: fallbackIndexPath;
|
||
|
||
response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)});
|
||
sendFile(response, resolvedFilePath);
|
||
}
|
||
|
||
function serveStatic(request, response, pathname) {
|
||
serveStaticFromRoot(response, pathname, webRoot, indexPath);
|
||
}
|
||
|
||
function serveAdminStatic(response, pathname) {
|
||
const adminPath = pathname === '/admin/' ? '/' : pathname.replace(/^\/admin/u, '');
|
||
serveStaticFromRoot(response, adminPath, adminWebRoot, adminIndexPath);
|
||
}
|
||
|
||
function proxyToApi(request, response) {
|
||
const targetUrl = new URL(request.url || '/', apiTarget);
|
||
const proxyRequest = http.request(
|
||
{
|
||
hostname: targetUrl.hostname,
|
||
method: request.method,
|
||
path: `${targetUrl.pathname}${targetUrl.search}`,
|
||
port: targetUrl.port || 80,
|
||
protocol: targetUrl.protocol,
|
||
headers: {
|
||
...request.headers,
|
||
host: apiTarget.host,
|
||
},
|
||
},
|
||
(proxyResponse) => {
|
||
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
|
||
proxyResponse.pipe(response);
|
||
},
|
||
);
|
||
|
||
proxyRequest.on('error', (error) => {
|
||
response.writeHead(502, {'content-type': 'application/json; charset=utf-8'});
|
||
response.end(JSON.stringify({ok: false, error: {code: 'API_PROXY_FAILED', message: error.message}}));
|
||
});
|
||
|
||
request.pipe(proxyRequest);
|
||
}
|
||
|
||
const server = http.createServer((request, response) => {
|
||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||
|
||
if (isProxyPath(url.pathname)) {
|
||
proxyToApi(request, response);
|
||
return;
|
||
}
|
||
|
||
if (url.pathname === '/admin') {
|
||
response.writeHead(301, {location: '/admin/'});
|
||
response.end();
|
||
return;
|
||
}
|
||
|
||
if (url.pathname === '/admin/' || url.pathname.startsWith('/admin/')) {
|
||
serveAdminStatic(response, url.pathname);
|
||
return;
|
||
}
|
||
|
||
serveStatic(request, response, url.pathname);
|
||
});
|
||
|
||
server.listen(webPort, webHost, () => {
|
||
console.log(`[web] listening on http://${webHost}:${webPort}, api target ${apiTarget.href}`);
|
||
});
|
||
WEB_SERVER
|
||
|
||
touch "${TARGET_DIR}/.env"
|
||
for env_file in "${TARGET_DIR}/.env" "${TARGET_DIR}/.env.local"; do
|
||
if [[ -f "${env_file}" ]]; then
|
||
grep -v '^GENARRATIVE_SPACETIME_ROOT_DIR=' "${env_file}" >"${env_file}.tmp" || true
|
||
mv "${env_file}.tmp" "${env_file}"
|
||
printf '\nGENARRATIVE_SPACETIME_ROOT_DIR=__GENARRATIVE_RUNTIME_SPACETIME_ROOT_DIR__\n' >>"${env_file}"
|
||
fi
|
||
done
|
||
|
||
cat >"${TARGET_DIR}/start.sh" <<'START_SCRIPT'
|
||
#!/usr/bin/env bash
|
||
|
||
set -euo pipefail
|
||
|
||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||
PID_DIR="${SCRIPT_DIR}/run"
|
||
LOG_DIR="${SCRIPT_DIR}/logs"
|
||
CLEAR_DATABASE=0
|
||
|
||
cd "${SCRIPT_DIR}"
|
||
|
||
load_env_file() {
|
||
local env_file="$1"
|
||
local line=""
|
||
local line_number=0
|
||
local key=""
|
||
local value=""
|
||
local utf8_bom=$'\xef\xbb\xbf'
|
||
|
||
if [[ ! -f "${env_file}" ]]; then
|
||
return
|
||
fi
|
||
|
||
echo "[start] 加载环境文件: ${env_file}"
|
||
|
||
# 环境文件按 dotenv 的 KEY=value 子集解析,避免 BOM 被 shell 当成命令名执行。
|
||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||
line_number=$((line_number + 1))
|
||
if [[ "${line_number}" -eq 1 ]]; then
|
||
line="${line#"${utf8_bom}"}"
|
||
fi
|
||
line="${line%$'\r'}"
|
||
|
||
if [[ "${line}" =~ ^[[:space:]]*$ || "${line}" =~ ^[[:space:]]*# ]]; then
|
||
continue
|
||
fi
|
||
|
||
if [[ ! "${line}" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||
echo "[start] 跳过不符合 KEY=value 的环境行: ${env_file}:${line_number}" >&2
|
||
continue
|
||
fi
|
||
|
||
key="${BASH_REMATCH[2]}"
|
||
value="${BASH_REMATCH[3]}"
|
||
if [[ "${#value}" -ge 2 && "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
|
||
value="${value:1:${#value}-2}"
|
||
value="${value//\\\"/\"}"
|
||
elif [[ "${#value}" -ge 2 && "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
|
||
value="${value:1:${#value}-2}"
|
||
fi
|
||
|
||
printf -v "${key}" '%s' "${value}"
|
||
export "${key}"
|
||
done <"${env_file}"
|
||
}
|
||
|
||
usage() {
|
||
cat <<'EOF'
|
||
用法:
|
||
./start.sh
|
||
./start.sh --clear-database
|
||
|
||
说明:
|
||
1. 启动当前发布包内的静态网站、SpacetimeDB 与 api-server。
|
||
2. 默认发布 spacetime_module.wasm 到 GENARRATIVE_SPACETIME_DATABASE,但不清库。
|
||
3. 默认遇到 schema 冲突时自动导出旧库、清库发布新模块并导入回灌。
|
||
4. 显式传入 --clear-database 时代表人工确认清库,不执行自动回灌。
|
||
EOF
|
||
}
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
-h|--help)
|
||
usage
|
||
exit 0
|
||
;;
|
||
--clear-database)
|
||
CLEAR_DATABASE=1
|
||
shift
|
||
;;
|
||
*)
|
||
echo "[start] 未知参数: $1" >&2
|
||
usage >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
load_env_file "${SCRIPT_DIR}/.env"
|
||
load_env_file "${SCRIPT_DIR}/.env.local"
|
||
|
||
SPACETIME_ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SCRIPT_DIR}/.spacetimedb}"
|
||
if [[ "${SPACETIME_ROOT_DIR}" == "__GENARRATIVE_RUNTIME_SPACETIME_ROOT_DIR__" ]]; then
|
||
SPACETIME_ROOT_DIR="${SCRIPT_DIR}/.spacetimedb"
|
||
fi
|
||
SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-__GENARRATIVE_DEFAULT_SPACETIME_HOST__}"
|
||
SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-__GENARRATIVE_DEFAULT_SPACETIME_PORT__}"
|
||
SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}"
|
||
SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-__GENARRATIVE_DEFAULT_SPACETIME_DATABASE__}"
|
||
SPACETIME_TIMEOUT_SECONDS="${GENARRATIVE_SPACETIME_TIMEOUT_SECONDS:-60}"
|
||
SPACETIME_MIGRATE_ON_CONFLICT="${GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT:-true}"
|
||
SPACETIME_MIGRATION_DIR="${GENARRATIVE_SPACETIME_MIGRATION_DIR:-}"
|
||
SPACETIME_MIGRATION_EXPORT_TOKEN="${GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN:-}"
|
||
SPACETIME_MIGRATION_IMPORT_TOKEN="${GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN:-}"
|
||
API_HOST="${GENARRATIVE_API_HOST:-__GENARRATIVE_DEFAULT_API_HOST__}"
|
||
API_PORT="${GENARRATIVE_API_PORT:-__GENARRATIVE_DEFAULT_API_PORT__}"
|
||
API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}"
|
||
WEB_HOST="${GENARRATIVE_WEB_HOST:-__GENARRATIVE_DEFAULT_WEB_HOST__}"
|
||
WEB_PORT="${GENARRATIVE_WEB_PORT:-__GENARRATIVE_DEFAULT_WEB_PORT__}"
|
||
MIGRATION_BOOTSTRAP_SECRET_FILE="${SCRIPT_DIR}/migration-bootstrap-secret.txt"
|
||
PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE="${SCRIPT_DIR}/deploy-state/migration-bootstrap-secret.previous.txt"
|
||
MIGRATION_SCRIPT_DIR="${SCRIPT_DIR}/scripts"
|
||
MIGRATION_EXPORT_SCRIPT="${MIGRATION_SCRIPT_DIR}/spacetime-export-migration-json.mjs"
|
||
MIGRATION_IMPORT_SCRIPT="${MIGRATION_SCRIPT_DIR}/spacetime-import-migration-json.mjs"
|
||
|
||
# 日志默认落文件,显式关闭 ANSI 颜色码,避免控制字符写入 *.log。
|
||
export NO_COLOR="${NO_COLOR:-1}"
|
||
export CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-never}"
|
||
|
||
require_command() {
|
||
local command_name="$1"
|
||
|
||
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||
echo "[start] 缺少命令: ${command_name}" >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
is_truthy() {
|
||
local normalized
|
||
|
||
normalized="$(printf "%s" "${1:-}" | tr '[:upper:]' '[:lower:]')"
|
||
case "${normalized}" in
|
||
1|true|yes|y|on)
|
||
return 0
|
||
;;
|
||
*)
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
timestamp_slug() {
|
||
date -u +%Y-%m-%dT%H-%M-%SZ
|
||
}
|
||
|
||
sanitize_path_segment() {
|
||
printf "%s" "$1" | tr -c 'A-Za-z0-9._-' '_'
|
||
}
|
||
|
||
validate_spacetime_database_name() {
|
||
local database="$1"
|
||
|
||
if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||
echo "[start] GENARRATIVE_SPACETIME_DATABASE 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
is_publish_conflict_output() {
|
||
local output="$1"
|
||
local normalized
|
||
|
||
normalized="$(printf "%s" "${output}" | tr '[:upper:]' '[:lower:]')"
|
||
[[ "${normalized}" == *"requires a manual migration"* ]] \
|
||
|| [[ "${normalized}" == *"manual migration"* ]] \
|
||
|| [[ "${normalized}" == *"schema"* && "${normalized}" == *"conflict"* ]] \
|
||
|| [[ "${normalized}" == *"clear-database"* ]] \
|
||
|| [[ "${normalized}" == *"clear database"* && "${normalized}" == *"publish"* ]]
|
||
}
|
||
|
||
read_migration_bootstrap_secret() {
|
||
local secret_file="$1"
|
||
local label="$2"
|
||
local secret=""
|
||
|
||
if [[ ! -f "${secret_file}" ]]; then
|
||
echo "[start] schema 冲突自动迁移需要${label}: ${secret_file}" >&2
|
||
echo "[start] 请使用默认带迁移引导密钥的发布包,或设置 GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=false 后人工处理。" >&2
|
||
return 1
|
||
fi
|
||
|
||
secret="$(tr -d '\r\n' <"${secret_file}")"
|
||
if [[ -z "${secret}" ]]; then
|
||
echo "[start] 迁移引导密钥为空${label}: ${secret_file}" >&2
|
||
return 1
|
||
fi
|
||
|
||
printf "%s" "${secret}"
|
||
}
|
||
|
||
read_export_migration_bootstrap_secret() {
|
||
if [[ -f "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||
read_migration_bootstrap_secret "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" "(旧模块导出)"
|
||
return
|
||
fi
|
||
|
||
read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(当前模块导出兜底)"
|
||
}
|
||
|
||
read_import_migration_bootstrap_secret() {
|
||
read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(新模块导入)"
|
||
}
|
||
|
||
require_migration_script() {
|
||
local script_path="$1"
|
||
|
||
if [[ ! -f "${script_path}" ]]; then
|
||
echo "[start] 发布包缺少 SpacetimeDB 迁移脚本: ${script_path}" >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
run_publish() {
|
||
local output_file="$1"
|
||
shift
|
||
|
||
set +e
|
||
spacetime --root-dir="${SPACETIME_ROOT_DIR}" "$@" >"${output_file}" 2>&1
|
||
local status=$?
|
||
set -e
|
||
cat "${output_file}"
|
||
return "${status}"
|
||
}
|
||
|
||
run_conflict_migration_publish() {
|
||
local export_bootstrap_secret=""
|
||
local import_bootstrap_secret=""
|
||
local export_auth_args=()
|
||
local import_auth_args=()
|
||
local migration_database_slug=""
|
||
local migration_root=""
|
||
local migration_file=""
|
||
local publish_log=""
|
||
|
||
if [[ -n "${SPACETIME_MIGRATION_EXPORT_TOKEN}" ]]; then
|
||
echo "[start] 使用 GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN 导出旧库"
|
||
export_auth_args=(--token "${SPACETIME_MIGRATION_EXPORT_TOKEN}")
|
||
else
|
||
export_bootstrap_secret="$(read_export_migration_bootstrap_secret)"
|
||
export_auth_args=(--bootstrap-secret "${export_bootstrap_secret}")
|
||
fi
|
||
|
||
if [[ -n "${SPACETIME_MIGRATION_IMPORT_TOKEN}" ]]; then
|
||
echo "[start] 使用 GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN 导入新库"
|
||
import_auth_args=(--token "${SPACETIME_MIGRATION_IMPORT_TOKEN}")
|
||
else
|
||
import_bootstrap_secret="$(read_import_migration_bootstrap_secret)"
|
||
import_auth_args=(--bootstrap-secret "${import_bootstrap_secret}")
|
||
fi
|
||
require_migration_script "${MIGRATION_EXPORT_SCRIPT}"
|
||
require_migration_script "${MIGRATION_IMPORT_SCRIPT}"
|
||
|
||
migration_database_slug="$(sanitize_path_segment "${SPACETIME_DATABASE}")"
|
||
migration_root="${SPACETIME_MIGRATION_DIR:-${SCRIPT_DIR}/database-migrations/${migration_database_slug}}"
|
||
mkdir -p "${migration_root}"
|
||
migration_file="${migration_root}/$(timestamp_slug).json"
|
||
|
||
echo "[start] 检测到 SpacetimeDB schema 冲突,开始导出旧库迁移 JSON: ${migration_file}"
|
||
node "${MIGRATION_EXPORT_SCRIPT}" \
|
||
--server "${SPACETIME_SERVER_URL}" \
|
||
--server-url "${SPACETIME_SERVER_URL}" \
|
||
--root-dir "${SPACETIME_ROOT_DIR}" \
|
||
--database "${SPACETIME_DATABASE}" \
|
||
"${export_auth_args[@]}" \
|
||
--out "${migration_file}" \
|
||
--note "deploy conflict export $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||
|
||
echo "[start] 清库发布新 SpacetimeDB wasm"
|
||
publish_log="$(mktemp)"
|
||
if ! run_publish "${publish_log}" \
|
||
publish \
|
||
"${SPACETIME_DATABASE}" \
|
||
--server "${SPACETIME_SERVER_URL}" \
|
||
--bin-path "${SCRIPT_DIR}/spacetime_module.wasm" \
|
||
--clear-database \
|
||
--yes; then
|
||
echo "[start] 清库发布失败,迁移 JSON 已保留: ${migration_file}" >&2
|
||
rm -f "${publish_log}"
|
||
exit 1
|
||
fi
|
||
rm -f "${publish_log}"
|
||
|
||
echo "[start] 导入迁移 JSON 回灌数据"
|
||
if ! node "${MIGRATION_IMPORT_SCRIPT}" \
|
||
--server "${SPACETIME_SERVER_URL}" \
|
||
--server-url "${SPACETIME_SERVER_URL}" \
|
||
--root-dir "${SPACETIME_ROOT_DIR}" \
|
||
--database "${SPACETIME_DATABASE}" \
|
||
"${import_auth_args[@]}" \
|
||
--in "${migration_file}" \
|
||
--replace-existing \
|
||
--note "deploy conflict import $(date -u +%Y-%m-%dT%H:%M:%SZ)"; then
|
||
echo "[start] 导入失败,迁移 JSON 已保留: ${migration_file}" >&2
|
||
exit 1
|
||
fi
|
||
|
||
echo "[start] schema 冲突自动迁移完成,迁移 JSON: ${migration_file}"
|
||
}
|
||
|
||
wait_for_spacetime() {
|
||
local process_pid="${1:-}"
|
||
local deadline=$((SECONDS + SPACETIME_TIMEOUT_SECONDS))
|
||
|
||
while ((SECONDS < deadline)); do
|
||
if is_spacetime_ready; then
|
||
return
|
||
fi
|
||
|
||
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
|
||
echo "[start] SpacetimeDB 进程在就绪前退出。" >&2
|
||
print_spacetime_start_diagnostics
|
||
exit 1
|
||
fi
|
||
|
||
sleep 0.5
|
||
done
|
||
|
||
if is_spacetime_ready; then
|
||
return
|
||
fi
|
||
|
||
echo "[start] 等待 SpacetimeDB 就绪超时: ${SPACETIME_SERVER_URL}" >&2
|
||
print_spacetime_start_diagnostics
|
||
exit 1
|
||
}
|
||
|
||
is_spacetime_ready() {
|
||
local output
|
||
|
||
if ! output="$(spacetime --root-dir="${SPACETIME_ROOT_DIR}" server ping "${SPACETIME_SERVER_URL}" 2>&1)"; then
|
||
return 1
|
||
fi
|
||
|
||
# SpacetimeDB CLI 2.1.0 在 502 Bad Gateway 时仍可能返回 0,不能只依赖退出码。
|
||
[[ "${output}" == *"Server is online:"* ]]
|
||
}
|
||
|
||
print_spacetime_start_diagnostics() {
|
||
local log_file="${LOG_DIR}/spacetimedb.log"
|
||
local root_owner=""
|
||
|
||
# SpacetimeDB 启动日志默认重定向到文件;失败时主动回显关键现场,避免只看到“就绪前退出”。
|
||
echo "[start] SpacetimeDB 启动诊断:" >&2
|
||
echo "[start] - server: ${SPACETIME_SERVER_URL}" >&2
|
||
echo "[start] - listen: ${SPACETIME_HOST}:${SPACETIME_PORT}" >&2
|
||
echo "[start] - root-dir: ${SPACETIME_ROOT_DIR}" >&2
|
||
echo "[start] - log: ${log_file}" >&2
|
||
|
||
if [[ -f "${log_file}" ]]; then
|
||
echo "[start] ${log_file} 最近 120 行:" >&2
|
||
tail -n 120 "${log_file}" >&2 || true
|
||
else
|
||
echo "[start] 尚未生成 ${log_file}" >&2
|
||
fi
|
||
|
||
echo "[start] server ping 结果:" >&2
|
||
spacetime --root-dir="${SPACETIME_ROOT_DIR}" server ping "${SPACETIME_SERVER_URL}" >&2 || true
|
||
|
||
if command -v ss >/dev/null 2>&1; then
|
||
echo "[start] ${SPACETIME_PORT} 端口监听检查:" >&2
|
||
ss -ltnp 2>/dev/null | awk -v listen=":${SPACETIME_PORT}" 'NR == 1 || index($0, listen) > 0 { print }' >&2 || true
|
||
elif command -v netstat >/dev/null 2>&1; then
|
||
echo "[start] ${SPACETIME_PORT} 端口监听检查:" >&2
|
||
netstat -ltnp 2>/dev/null | awk -v listen=":${SPACETIME_PORT}" 'NR == 1 || index($0, listen) > 0 { print }' >&2 || true
|
||
fi
|
||
|
||
root_owner="$(describe_spacetime_root_owner || true)"
|
||
if [[ -n "${root_owner}" ]]; then
|
||
echo "[start] root-dir 相关 SpacetimeDB 进程:" >&2
|
||
echo "${root_owner}" >&2
|
||
fi
|
||
}
|
||
|
||
describe_spacetime_root_owner() {
|
||
if command -v ps >/dev/null 2>&1; then
|
||
ps -eo user=,pid=,ppid=,stat=,comm=,args= 2>/dev/null | awk -v root_dir="${SPACETIME_ROOT_DIR}" '
|
||
{
|
||
user = $1
|
||
pid = $2
|
||
ppid = $3
|
||
stat = $4
|
||
command = $5
|
||
args = $0
|
||
sub(/^[[:space:]]*[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]*/, "", args)
|
||
name = command
|
||
sub(/^.*\//, "", name)
|
||
|
||
# 只认真实的 SpacetimeDB 启动进程,避免 .spacetimedb 路径让 grep/awk 自身误命中。
|
||
if ((name == "spacetime" || name == "spacetimedb-cli") && index(args, root_dir) > 0) {
|
||
print user " " pid " " ppid " " stat " " name " " args
|
||
}
|
||
}
|
||
' || true
|
||
fi
|
||
}
|
||
|
||
sync_ubuntu_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 spacetime_command=""
|
||
local resolved_command=""
|
||
local install_dir=""
|
||
local root_bin="${root_dir}/bin"
|
||
local parent_dir=""
|
||
local share_bin_dir=""
|
||
local version_dir=""
|
||
|
||
if [[ -x "${target_cli}" && -x "${target_standalone}" ]]; then
|
||
return
|
||
fi
|
||
|
||
spacetime_command="$(command -v spacetime || true)"
|
||
if [[ -z "${spacetime_command}" ]]; then
|
||
echo "[start] 缺少 spacetime 命令,无法同步 SpacetimeDB 安装。" >&2
|
||
exit 1
|
||
fi
|
||
|
||
resolved_command="${spacetime_command}"
|
||
if command -v readlink >/dev/null 2>&1; then
|
||
resolved_command="$(readlink -f "${spacetime_command}" 2>/dev/null || echo "${spacetime_command}")"
|
||
fi
|
||
|
||
install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)"
|
||
mkdir -p "${root_bin}"
|
||
|
||
for share_bin_dir in \
|
||
"/usr/.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 "[start] 同步 Ubuntu 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}"
|
||
return
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if [[ -d "${install_dir}/bin" ]]; then
|
||
echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}"
|
||
cp -a "${install_dir}/bin/." "${root_bin}/"
|
||
elif [[ -x "${install_dir}/current/spacetimedb-cli" ]]; then
|
||
echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${install_dir} -> ${root_bin}"
|
||
cp -a "${install_dir}/." "${root_bin}/"
|
||
elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then
|
||
echo "[start] 同步 Ubuntu 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 "[start] 同步 Ubuntu SpacetimeDB 安装: ${parent_dir}/bin -> ${root_bin}"
|
||
cp -a "${parent_dir}/bin/." "${root_bin}/"
|
||
else
|
||
echo "[start] 未能从 spacetime 命令路径推断完整 SpacetimeDB 安装目录: ${resolved_command}" >&2
|
||
fi
|
||
fi
|
||
|
||
if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then
|
||
echo "[start] 同步 SpacetimeDB 安装后仍未找到完整 current 目录。" >&2
|
||
echo "[start] 需要同时存在: ${target_cli} 与 ${target_standalone}" >&2
|
||
echo "[start] 请确认 Ubuntu 上的 spacetime 安装目录包含 spacetimedb-cli 和 spacetimedb-standalone。" >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
start_process() {
|
||
local name="$1"
|
||
shift
|
||
local pid_file="${PID_DIR}/${name}.pid"
|
||
local log_file="${LOG_DIR}/${name}.log"
|
||
|
||
if [[ -f "${pid_file}" ]] && kill -0 "$(cat "${pid_file}")" 2>/dev/null; then
|
||
echo "[start] ${name} 已在运行: $(cat "${pid_file}")"
|
||
return
|
||
fi
|
||
|
||
echo "[start] 启动 ${name}"
|
||
JENKINS_NODE_COOKIE=dontKillMe BUILD_ID=dontKillMe nohup "$@" >"${log_file}" 2>&1 &
|
||
echo "$!" >"${pid_file}"
|
||
}
|
||
|
||
validate_spacetime_database_name "${SPACETIME_DATABASE}"
|
||
|
||
echo "[start] SpacetimeDB 发布配置:"
|
||
echo "[start] - database: ${SPACETIME_DATABASE}"
|
||
echo "[start] - server: ${SPACETIME_SERVER_URL}"
|
||
echo "[start] - root-dir: ${SPACETIME_ROOT_DIR}"
|
||
|
||
require_command node
|
||
require_command spacetime
|
||
|
||
mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_ROOT_DIR}"
|
||
sync_ubuntu_spacetime_install "${SPACETIME_ROOT_DIR}"
|
||
|
||
SPACETIME_PID=""
|
||
if is_spacetime_ready; then
|
||
echo "[start] 复用已运行的 SpacetimeDB: ${SPACETIME_SERVER_URL}"
|
||
else
|
||
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner)"
|
||
if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then
|
||
echo "[start] 当前 root-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2
|
||
echo "[start] 目标地址未就绪: ${SPACETIME_SERVER_URL}" >&2
|
||
echo "[start] 如需复用,请把 GENARRATIVE_SPACETIME_PORT 改为占用实例实际端口;如需重启,请先停止下列进程。" >&2
|
||
echo "${SPACETIME_ROOT_OWNER}" >&2
|
||
exit 1
|
||
fi
|
||
|
||
start_process spacetimedb \
|
||
spacetime \
|
||
--root-dir="${SPACETIME_ROOT_DIR}" \
|
||
start \
|
||
--edition standalone \
|
||
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"
|
||
SPACETIME_PID="$(cat "${PID_DIR}/spacetimedb.pid")"
|
||
fi
|
||
|
||
wait_for_spacetime "${SPACETIME_PID}"
|
||
|
||
PUBLISH_ARGS=(
|
||
publish
|
||
"${SPACETIME_DATABASE}"
|
||
--server "${SPACETIME_SERVER_URL}"
|
||
--bin-path "${SCRIPT_DIR}/spacetime_module.wasm"
|
||
--yes
|
||
)
|
||
|
||
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
|
||
# 发布包清库模式只在 schema 冲突时删除旧模块数据,避免无冲突升级误清数据。
|
||
PUBLISH_ARGS+=(-c=on-conflict)
|
||
fi
|
||
|
||
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
|
||
if [[ -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||
echo "[start] 迁移引导密钥: $(cat "${MIGRATION_BOOTSTRAP_SECRET_FILE}")"
|
||
else
|
||
echo "[start] 未启用迁移引导密钥。"
|
||
fi
|
||
PUBLISH_LOG="$(mktemp)"
|
||
if ! run_publish "${PUBLISH_LOG}" "${PUBLISH_ARGS[@]}"; then
|
||
PUBLISH_OUTPUT="$(cat "${PUBLISH_LOG}")"
|
||
rm -f "${PUBLISH_LOG}"
|
||
if [[ "${CLEAR_DATABASE}" -eq 0 ]] \
|
||
&& is_truthy "${SPACETIME_MIGRATE_ON_CONFLICT}" \
|
||
&& is_publish_conflict_output "${PUBLISH_OUTPUT}"; then
|
||
run_conflict_migration_publish
|
||
else
|
||
if [[ "${CLEAR_DATABASE}" -eq 0 ]] && ! is_truthy "${SPACETIME_MIGRATE_ON_CONFLICT}"; then
|
||
echo "[start] 已禁用 schema 冲突自动迁移: GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=${SPACETIME_MIGRATE_ON_CONFLICT}" >&2
|
||
fi
|
||
echo "[start] SpacetimeDB 发布失败。" >&2
|
||
echo "[start] 如果错误包含 403 Forbidden 或 is not authorized,通常是当前 CLI 身份无权更新目标数据库。" >&2
|
||
echo "[start] 当前 start.sh 使用的 CLI root: ${SPACETIME_ROOT_DIR}" >&2
|
||
spacetime --root-dir="${SPACETIME_ROOT_DIR}" login show >&2 || true
|
||
echo "[start] 如果目标是本地库且可以清空数据:先执行 ./stop.sh,备份或删除 ${SPACETIME_ROOT_DIR},再重新执行 ./start.sh --clear-database。" >&2
|
||
echo "[start] 如果目标是 Maincloud 或必须保留数据:请切换到创建该数据库的 SpacetimeDB 身份,或把 GENARRATIVE_SPACETIME_DATABASE 改为当前身份有权限的库。" >&2
|
||
exit 1
|
||
fi
|
||
else
|
||
rm -f "${PUBLISH_LOG}"
|
||
fi
|
||
|
||
export GENARRATIVE_API_HOST="${API_HOST}"
|
||
export GENARRATIVE_API_PORT="${API_PORT}"
|
||
export GENARRATIVE_API_LOG="${API_LOG}"
|
||
export GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER_URL}"
|
||
export GENARRATIVE_SPACETIME_DATABASE="${SPACETIME_DATABASE}"
|
||
start_process api-server "${SCRIPT_DIR}/api-server"
|
||
|
||
export GENARRATIVE_WEB_HOST="${WEB_HOST}"
|
||
export GENARRATIVE_WEB_PORT="${WEB_PORT}"
|
||
export GENARRATIVE_API_TARGET="http://${API_HOST}:${API_PORT}"
|
||
start_process web node "${SCRIPT_DIR}/web-server.mjs"
|
||
|
||
echo "[start] 完成"
|
||
echo "[start] Web: http://${WEB_HOST}:${WEB_PORT}"
|
||
echo "[start] API: http://${API_HOST}:${API_PORT}"
|
||
echo "[start] SpacetimeDB: ${SPACETIME_SERVER_URL}"
|
||
echo "[start] SpacetimeDB database: ${SPACETIME_DATABASE}"
|
||
START_SCRIPT
|
||
|
||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_HOST__" "${SPACETIME_HOST}"
|
||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_PORT__" "${SPACETIME_PORT}"
|
||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_DATABASE__" "${DATABASE}"
|
||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_API_HOST__" "${API_HOST}"
|
||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_API_PORT__" "${API_PORT}"
|
||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_WEB_HOST__" "${WEB_HOST}"
|
||
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_WEB_PORT__" "${WEB_PORT}"
|
||
|
||
cat >"${TARGET_DIR}/stop.sh" <<'STOP_SCRIPT'
|
||
#!/usr/bin/env bash
|
||
|
||
set -euo pipefail
|
||
|
||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||
PID_DIR="${SCRIPT_DIR}/run"
|
||
|
||
stop_process() {
|
||
local name="$1"
|
||
local pid_file="${PID_DIR}/${name}.pid"
|
||
|
||
if [[ ! -f "${pid_file}" ]]; then
|
||
echo "[stop] ${name} 未记录 pid"
|
||
return
|
||
fi
|
||
|
||
local pid
|
||
pid="$(cat "${pid_file}")"
|
||
|
||
if kill -0 "${pid}" 2>/dev/null; then
|
||
echo "[stop] 停止 ${name} (pid=${pid})"
|
||
kill "${pid}" 2>/dev/null || true
|
||
sleep 0.5
|
||
if kill -0 "${pid}" 2>/dev/null; then
|
||
kill -9 "${pid}" 2>/dev/null || true
|
||
fi
|
||
else
|
||
echo "[stop] ${name} 未运行"
|
||
fi
|
||
|
||
rm -f "${pid_file}"
|
||
}
|
||
|
||
stop_process web
|
||
stop_process api-server
|
||
stop_process spacetimedb
|
||
|
||
echo "[stop] 完成"
|
||
STOP_SCRIPT
|
||
|
||
chmod +x "${TARGET_DIR}/start.sh" "${TARGET_DIR}/stop.sh"
|
||
|
||
cat >"${TARGET_DIR}/README.md" <<'EOF'
|
||
# Genarrative Ubuntu Release
|
||
|
||
构建时间:`__GENARRATIVE_BUILD_NAME__`
|
||
|
||
## 内容
|
||
|
||
- \`.env\` / \`.env.local\`:从仓库根目录复制的环境文件,同时各保留一份到 \`web/\`
|
||
- \`web/\`:主前端 Vite release 静态资源,\`web/admin/\` 为后台管理前端静态资源
|
||
- \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件
|
||
- \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块
|
||
- \`migration-bootstrap-secret.txt\`:本发布包 wasm 编译时注入的迁移引导密钥;服务器 \`start.sh\` 发布时会显示,迁移授权完成后可删除
|
||
- \`scripts/spacetime-*.mjs\`:部署时 schema 冲突自动导出、导入回灌使用的 SpacetimeDB 迁移脚本
|
||
- \`web-server.mjs\`:静态网站与 API 反代入口
|
||
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
|
||
|
||
## 启动
|
||
|
||
\`\`\`bash
|
||
./start.sh
|
||
\`\`\`
|
||
|
||
默认不清空 SpacetimeDB。如需开发库清库重发:
|
||
|
||
\`\`\`bash
|
||
./start.sh --clear-database
|
||
\`\`\`
|
||
|
||
默认启动会先尝试无清库发布;如果 SpacetimeDB 返回 schema 冲突,\`start.sh\` 会把旧库导出到 \`database-migrations/<database>/\`,随后清库发布新 wasm,并用 \`--replace-existing\` 导入回灌。
|
||
|
||
## 入口
|
||
|
||
- 主站:\`http://<web-host>:<web-port>/\`
|
||
- 后台:\`http://<web-host>:<web-port>/admin/\`
|
||
|
||
## 环境变量
|
||
|
||
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
|
||
- 环境文件复制进发布包时会移除 UTF-8 BOM 与 CRLF;启动时也会按 \`KEY=value\` 子集解析,跳过不合法行。
|
||
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-host`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数;Web 默认只监听 `127.0.0.1`。
|
||
- 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
|
||
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
|
||
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
|
||
- \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\`
|
||
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\` / \`GENARRATIVE_SPACETIME_TOKEN\`
|
||
- \`GENARRATIVE_SPACETIME_ROOT_DIR\`:默认使用发布目录下的 \`.spacetimedb/\`,同时承载本地 SpacetimeDB 运行数据与 CLI 身份。
|
||
- \`GENARRATIVE_SPACETIME_TIMEOUT_SECONDS\`:等待 SpacetimeDB 就绪的秒数,默认 \`60\`。
|
||
- \`GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT\`:默认 \`true\`,普通发布遇到 schema 冲突时自动导出、清库发布、导入回灌;设为 \`false\` 时保留原始发布失败。
|
||
- \`GENARRATIVE_SPACETIME_MIGRATION_DIR\`:自动迁移 JSON 输出目录,默认 \`database-migrations/<database>/\`。
|
||
- OSS、LLM、短信、微信、SpacetimeDB owner token 等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理;后台表统计读取 private 表时需要 \`GENARRATIVE_SPACETIME_TOKEN\` 对目标库有 owner 权限。
|
||
- 迁移引导密钥由构建发布包时随机生成,构建日志和服务器 \`start.sh\` 发布日志都会显示同一份密钥。
|
||
EOF
|
||
replace_placeholder_in_file "${TARGET_DIR}/README.md" "__GENARRATIVE_BUILD_NAME__" "${BUILD_NAME}"
|
||
|
||
BUILD_COMPLETED=1
|
||
|
||
if [[ "${UPLOAD_ENABLED}" -eq 1 ]]; then
|
||
echo "[deploy:rust] 创建远端目录: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||
ssh -i "${NORMALIZED_SSH_KEY}" "${REMOTE_TARGET}" "mkdir -p $(remote_shell_quote "${REMOTE_DIR}")"
|
||
|
||
echo "[deploy:rust] 上传发布包: ${TARGET_DIR} -> ${REMOTE_TARGET}:${REMOTE_DIR}/"
|
||
scp -r -i "${NORMALIZED_SSH_KEY}" "${TARGET_DIR}" "${REMOTE_TARGET}:${REMOTE_DIR}/"
|
||
fi
|
||
|
||
echo "[deploy:rust] 完成: ${TARGET_DIR}"
|