Files
Genarrative/scripts/deploy-rust-remote.sh
2026-04-23 00:38:09 +08:00

613 lines
17 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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默认 genarrative-dev
--api-port <port> api-server 端口,默认 8082
--web-port <port> 静态网站端口,默认 3000
--spacetime-port <port> SpacetimeDB 端口,默认 3101
--ssh-key <path> 上传使用的 SSH 私钥,默认 ~\.ssh\dsk.pem
--remote <user@host> 上传目标 SSH 主机,默认 ubuntu@82.157.175.59
--remote-dir <path> 上传目标目录,默认 /home/ubuntu/genarrative
--skip-upload 只生成本地发布包,不上传服务器
--skip-web-build 跳过 Vite 构建,仅用于调试
--skip-api-build 跳过 api-server 构建,仅用于调试
--skip-spacetime-build 跳过 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_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")"
}
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="0.0.0.0"
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
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
;;
*)
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
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
WEB_DIR="${TARGET_DIR}/web"
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
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}"
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
)
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"
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 webHost = process.env.GENARRATIVE_WEB_HOST || '0.0.0.0';
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 proxyPrefixes = [
'/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 serveStatic(request, response, pathname) {
const decodedPath = decodeURIComponent(pathname);
const relativePath = decodedPath === '/' ? '/index.html' : decodedPath;
const filePath = path.normalize(path.join(webRoot, relativePath));
const safeRelativePath = path.relative(webRoot, 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
: indexPath;
response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)});
sendFile(response, resolvedFilePath);
}
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;
}
serveStatic(request, response, url.pathname);
});
server.listen(webPort, webHost, () => {
console.log(`[web] listening on http://${webHost}:${webPort}, api target ${apiTarget.href}`);
});
WEB_SERVER
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"
SPACETIME_DATA_DIR="${GENARRATIVE_SPACETIME_DATA_DIR:-${SCRIPT_DIR}/spacetimedb-data}"
SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-127.0.0.1}"
SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-3101}"
SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}"
SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-genarrative-dev}"
API_HOST="${GENARRATIVE_API_HOST:-127.0.0.1}"
API_PORT="${GENARRATIVE_API_PORT:-8082}"
API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}"
WEB_HOST="${GENARRATIVE_WEB_HOST:-0.0.0.0}"
WEB_PORT="${GENARRATIVE_WEB_PORT:-3000}"
CLEAR_DATABASE=0
cd "${SCRIPT_DIR}"
usage() {
cat <<'EOF'
用法:
./start.sh
./start.sh --clear-database
说明:
1. 启动当前发布包内的静态网站、SpacetimeDB 与 api-server。
2. 默认发布 spacetime_module.wasm 到 GENARRATIVE_SPACETIME_DATABASE但不清库。
3. 只有显式传入 --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
require_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "[start] 缺少命令: ${command_name}" >&2
exit 1
fi
}
wait_for_spacetime() {
local deadline=$((SECONDS + 60))
while ((SECONDS < deadline)); do
if spacetime server ping "${SPACETIME_SERVER_URL}" >/dev/null 2>&1; then
return
fi
sleep 0.5
done
echo "[start] 等待 SpacetimeDB 就绪超时: ${SPACETIME_SERVER_URL}" >&2
exit 1
}
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}"
nohup "$@" >"${log_file}" 2>&1 &
echo "$!" >"${pid_file}"
}
require_command node
require_command spacetime
mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_DATA_DIR}"
start_process spacetimedb \
spacetime \
start \
--data-dir "${SPACETIME_DATA_DIR}" \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
--non-interactive
wait_for_spacetime
PUBLISH_ARGS=(
publish
"${SPACETIME_DATABASE}"
--server "${SPACETIME_SERVER_URL}"
--bin-path "${SCRIPT_DIR}/spacetime_module.wasm"
--yes
)
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
PUBLISH_ARGS+=(--delete-data always)
fi
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
spacetime "${PUBLISH_ARGS[@]}"
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}"
START_SCRIPT
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
构建时间:\`${BUILD_NAME}\`
## 内容
- \`web/\`Vite release 静态资源
- \`api-server\`x86_64-unknown-linux-gnu release 可执行文件
- \`spacetime_module.wasm\`wasm32-unknown-unknown release 模块
- \`web-server.mjs\`:静态网站与 API 反代入口
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
## 启动
\`\`\`bash
./start.sh
\`\`\`
默认不清空 SpacetimeDB。如需开发库清库重发
\`\`\`bash
./start.sh --clear-database
\`\`\`
## 环境变量
- \`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_DATA_DIR\`
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
EOF
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}"