合并主分支最新改动
同步 master 的最新功能与修复 保留 AI 游戏创作客户端分支现有实现 # Conflicts: # docs/project-memory/shared-memory/decision-log.md # docs/project-memory/shared-memory/pitfalls.md # scripts/dev.mjs # server-rs/crates/api-server/src/editor_screen_background_decision.rs # server-rs/crates/api-server/src/modules/admin.rs # src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
@@ -105,6 +105,16 @@ generate_migration_bootstrap_secret() {
|
||||
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
|
||||
}
|
||||
|
||||
persist_generated_migration_bootstrap_secret() {
|
||||
local secret_dir="${SERVER_RS_DIR}/.spacetimedb/build-secrets"
|
||||
local secret_path="${secret_dir}/${BUILD_NAME}.txt"
|
||||
mkdir -p "${secret_dir}"
|
||||
chmod 700 "${secret_dir}" 2>/dev/null || true
|
||||
(umask 077; printf '%s\n' "${MIGRATION_BOOTSTRAP_SECRET}" >"${secret_path}")
|
||||
chmod 600 "${secret_path}" 2>/dev/null || true
|
||||
echo "[production-release] 自动生成的迁移引导密钥仅写入本机受保护文件: ${secret_path}"
|
||||
}
|
||||
|
||||
prepare_migration_bootstrap_secret() {
|
||||
local secret_source="generated"
|
||||
|
||||
@@ -114,48 +124,43 @@ prepare_migration_bootstrap_secret() {
|
||||
|
||||
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256
|
||||
echo "[production-release] 未启用迁移引导密钥。"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256:-}" ]]; then
|
||||
if [[ ! "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256}" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
||||
echo "[production-release] 迁移引导密钥 SHA-256 必须是 64 位十六进制。" >&2
|
||||
exit 1
|
||||
fi
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256,,}"
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
echo "[production-release] 已准备迁移引导密钥摘要: source=sha256-environment"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET:-}" ]]; then
|
||||
MIGRATION_BOOTSTRAP_SECRET="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET}"
|
||||
secret_source="environment"
|
||||
else
|
||||
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
persist_generated_migration_bootstrap_secret
|
||||
fi
|
||||
|
||||
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
|
||||
echo "[production-release] 迁移引导密钥至少需要 16 个字符。" >&2
|
||||
if [[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
||||
echo "[production-release] 迁移引导密钥必须是 64 位十六进制高熵值。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local digest_line
|
||||
digest_line="$(printf '%s' "${MIGRATION_BOOTSTRAP_SECRET}" | sha256sum)"
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="${digest_line%% *}"
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
|
||||
echo "[production-release] 已准备迁移引导密钥: source=${secret_source}, length=${#MIGRATION_BOOTSTRAP_SECRET}"
|
||||
}
|
||||
|
||||
write_migration_bootstrap_secret_file() {
|
||||
local target_path="${TARGET_DIR}/migration-bootstrap-secret.txt"
|
||||
|
||||
if [[ "${BUILD_SPACETIME}" -ne 1 || "${SKIP_SPACETIME_BUILD}" -eq 1 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z "${MIGRATION_BOOTSTRAP_SECRET}" ]]; then
|
||||
echo "[production-release] 迁移引导密钥为空,无法写入发布产物。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${target_path}"
|
||||
chmod 600 "${target_path}" 2>/dev/null || true
|
||||
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=1
|
||||
echo "[production-release] 已写入迁移引导密钥文件: ${target_path}"
|
||||
}
|
||||
|
||||
write_release_manifest() {
|
||||
RELEASE_MANIFEST_PATH="${TARGET_DIR}/release-manifest.json" \
|
||||
RELEASE_VERSION="${BUILD_NAME}" \
|
||||
@@ -167,7 +172,7 @@ write_release_manifest() {
|
||||
RELEASE_INCLUDE_API="${BUILD_API}" \
|
||||
RELEASE_INCLUDE_SPACETIME="${BUILD_SPACETIME}" \
|
||||
RELEASE_INCLUDE_PINGORA_GATEWAY="${INCLUDE_PINGORA_GATEWAY}" \
|
||||
RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET_ARTIFACT}" \
|
||||
RELEASE_MIGRATION_BOOTSTRAP_SECRET_SHA256="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256:-}" \
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -200,20 +205,14 @@ if (process.env.RELEASE_INCLUDE_PINGORA_GATEWAY === '1') {
|
||||
checksum_path: 'pingora-gateway.sha256',
|
||||
});
|
||||
}
|
||||
if (process.env.RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET === '1') {
|
||||
artifacts.push({
|
||||
component: 'spacetime-module',
|
||||
path: 'migration-bootstrap-secret.txt',
|
||||
sensitive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: process.env.RELEASE_VERSION,
|
||||
source_branch: process.env.RELEASE_SOURCE_BRANCH,
|
||||
source_commit: process.env.RELEASE_SOURCE_COMMIT,
|
||||
built_at: process.env.RELEASE_BUILT_AT,
|
||||
component_type: process.env.RELEASE_COMPONENT,
|
||||
migration_bootstrap_secret_sha256:
|
||||
process.env.RELEASE_MIGRATION_BOOTSTRAP_SECRET_SHA256 || null,
|
||||
artifacts,
|
||||
};
|
||||
|
||||
@@ -233,7 +232,6 @@ SKIP_SPACETIME_BUILD=0
|
||||
SKIP_PINGORA_GATEWAY_BUILD=0
|
||||
INCLUDE_PINGORA_GATEWAY=0
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=0
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
|
||||
BUILD_COMPLETED=0
|
||||
|
||||
@@ -448,6 +446,17 @@ if [[ "${BUILD_WEB}" -eq 1 ]]; then
|
||||
MAINTENANCE_HTML
|
||||
fi
|
||||
|
||||
if [[ ! -f "${WEB_DIR}/404.html" ]]; then
|
||||
echo "[production-release] Web 发布包缺少品牌 404 页面: ${WEB_DIR}/404.html" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[production-release] 校验默认维护页不包含临时公告"
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
node scripts/check-maintenance-page.mjs --file "${WEB_DIR}/maintenance.html"
|
||||
)
|
||||
|
||||
echo "[production-release] 规范 Web 静态资源权限"
|
||||
find "${WEB_DIR}" -type d -exec chmod 755 {} +
|
||||
find "${WEB_DIR}" -type f -exec chmod 644 {} +
|
||||
@@ -508,7 +517,6 @@ fi
|
||||
if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then
|
||||
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
|
||||
write_sha256_file "${TARGET_DIR}/spacetime_module.wasm"
|
||||
write_migration_bootstrap_secret_file
|
||||
fi
|
||||
|
||||
mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/scripts/deploy" "${TARGET_DIR}/scripts/ops" "${TARGET_DIR}/deploy"
|
||||
@@ -549,6 +557,7 @@ chmod +x \
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET_DIR}/scripts/database-export.mjs" "数据库导出脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-maintain-external-generation-jobs.mjs" "${TARGET_DIR}/scripts/spacetime-maintain-external-generation-jobs.mjs" "外部生成任务维护脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本"
|
||||
@@ -584,10 +593,10 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
||||
- \`api-server\`:生产 Linux release 可执行文件。
|
||||
- \`pingora-gateway\`:可选 Pingora 影子网关可执行文件,仅在显式 \`--include-pingora-gateway\` 时包含。
|
||||
- \`spacetime_module.wasm\`:SpacetimeDB 模块 wasm。
|
||||
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text,授权完成后不要长期留在公开归档中。
|
||||
- 迁移引导密钥不进入发布包;WASM 仅嵌入 SHA-256 摘要,原始 secret 由 Jenkins Secret File 在 Stdb publish 时受保护地交付。
|
||||
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
|
||||
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
|
||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、生产健康巡检脚本、Pingora release readiness 聚合门禁、直连启用 / 回退、realpath canary 启用 / 关闭、health patrol env 切换 / TLS 证书同步 / 预检 / direct live smoke / canary live smoke / canary access log 对账 / current release 自审 / 直连彩排状态 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、外部生成任务 payload 压缩 / 摘要回填脚本、数据库 OSS 备份脚本、生产健康巡检脚本、Pingora release readiness 聚合门禁、直连启用 / 回退、realpath canary 启用 / 关闭、health patrol env 切换 / TLS 证书同步 / 预检 / direct live smoke / canary live smoke / canary access log 对账 / current release 自审 / 直连彩排状态 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||
- \`scripts/deploy/production-api-deploy.sh\`:API Deploy 执行入口;同目录的 \`maintenance-on.sh\` / \`maintenance-off.sh\` 必须来自同一发布包。
|
||||
- \`deploy/\`:systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
|
||||
|
||||
|
||||
@@ -47,6 +47,31 @@ if (typoKeys.length > 0) {
|
||||
);
|
||||
}
|
||||
|
||||
// 阿里云通用抠图兜底层:默认 enabled=true,但 AccessKey 缺失时会被静默跳过,
|
||||
// BgFilter 失败直接本地去背(质量下降)。属可选降级项,只告警不阻断部署。
|
||||
console.log('[api-server-env] 抠图兜底(阿里云通用抠图)配置检查');
|
||||
const mattingEnabled = env.GENARRATIVE_ALIYUN_MATTING_ENABLED !== 'false';
|
||||
const mattingKeyPresent =
|
||||
hasValue(env.GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID) ||
|
||||
hasValue(env.ALIBABA_CLOUD_ACCESS_KEY_ID);
|
||||
const mattingSecretPresent =
|
||||
hasValue(env.GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET) ||
|
||||
hasValue(env.ALIBABA_CLOUD_ACCESS_KEY_SECRET);
|
||||
printStatus(
|
||||
'GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_ID',
|
||||
mattingKeyPresent,
|
||||
);
|
||||
printStatus(
|
||||
'GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET / ALIBABA_CLOUD_ACCESS_KEY_SECRET',
|
||||
mattingSecretPresent,
|
||||
);
|
||||
if (mattingEnabled && !(mattingKeyPresent && mattingSecretPresent)) {
|
||||
console.warn(
|
||||
'[api-server-env] 警告:GENARRATIVE_ALIYUN_MATTING_ENABLED 未关闭但缺少 AccessKey,' +
|
||||
'阿里云抠图兜底层将被跳过,BgFilter 失败后直接本地去背(质量下降)。',
|
||||
);
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(`[api-server-env] 缺少:${missing.join(', ')}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -5,12 +5,14 @@ import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync}
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {buildAuthorization, buildCanonicalQuery, uploadArchive} from './database-backup-to-oss.mjs';
|
||||
|
||||
const BACKUP_SCRIPT = path.resolve('scripts/database-backup-to-oss.mjs');
|
||||
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'genarrative-database-backup-check-'));
|
||||
const failures = [];
|
||||
|
||||
try {
|
||||
main();
|
||||
await main();
|
||||
} finally {
|
||||
rmSync(tmpRoot, {recursive: true, force: true});
|
||||
}
|
||||
@@ -25,9 +27,58 @@ if (failures.length > 0) {
|
||||
|
||||
console.log('[check:database-backup] OK');
|
||||
|
||||
function main() {
|
||||
async function main() {
|
||||
assertCanonicalQueryAndAuthorizationIncludeMultipartParameters();
|
||||
assertInsufficientSpaceStopsBeforeServiceChanges();
|
||||
assertArchiveFailureStillRestoresDependentServices();
|
||||
await assertMultipartUploadRetriesAndVerifiesRemoteLength();
|
||||
await assertMissingPartEtagAbortsMultipartUpload();
|
||||
await assertCompleteResponseAmbiguityUsesHeadVerification();
|
||||
await assertHeadLengthMismatchAbortsMultipartUpload();
|
||||
}
|
||||
|
||||
function assertCanonicalQueryAndAuthorizationIncludeMultipartParameters() {
|
||||
assertEqual(buildCanonicalQuery({uploads: null}), 'uploads', 'InitiateMultipartUpload 必须使用无等号的 uploads 参数。');
|
||||
assertEqual(
|
||||
buildCanonicalQuery({uploadId: 'abc+/= xyz', partNumber: 12}),
|
||||
'partNumber=12&uploadId=abc%2B%2F%3D%20xyz',
|
||||
'multipart query 必须按 key 排序并使用 RFC3986 编码。',
|
||||
);
|
||||
|
||||
const date = new Date('2026-07-13T10:20:30.000Z');
|
||||
const headers = {
|
||||
host: 'genarrative-test.oss-cn-shanghai.aliyuncs.com',
|
||||
'content-type': 'application/octet-stream',
|
||||
'x-oss-content-sha256': 'UNSIGNED-PAYLOAD',
|
||||
'x-oss-date': '20260713T102030Z',
|
||||
};
|
||||
const withoutQuery = buildAuthorization({
|
||||
method: 'PUT',
|
||||
bucket: 'genarrative-test',
|
||||
endpoint: 'oss-cn-shanghai.aliyuncs.com',
|
||||
objectKey: 'database-backups/test/archive.tar.gz',
|
||||
accessKeyId: 'test-access-key',
|
||||
accessKeySecret: 'test-access-secret',
|
||||
headers,
|
||||
date,
|
||||
});
|
||||
const withQuery = buildAuthorization({
|
||||
method: 'PUT',
|
||||
bucket: 'genarrative-test',
|
||||
endpoint: 'oss-cn-shanghai.aliyuncs.com',
|
||||
objectKey: 'database-backups/test/archive.tar.gz',
|
||||
accessKeyId: 'test-access-key',
|
||||
accessKeySecret: 'test-access-secret',
|
||||
headers,
|
||||
date,
|
||||
queries: {partNumber: 12, uploadId: 'abc+/= xyz'},
|
||||
});
|
||||
assertNotEqual(withQuery, withoutQuery, 'multipart query 必须参与 V4 Authorization 计算。');
|
||||
assertEqual(
|
||||
withQuery,
|
||||
'OSS4-HMAC-SHA256 Credential=test-access-key/20260713/cn-shanghai/oss/aliyun_v4_request,AdditionalHeaders=host,Signature=9323dd3b7272b52f416c4d32115fcc00460eaccdcdaf011575c2502a63a27b1f',
|
||||
'multipart V4 Authorization 必须保持固定签名向量。',
|
||||
);
|
||||
}
|
||||
|
||||
function assertInsufficientSpaceStopsBeforeServiceChanges() {
|
||||
@@ -78,6 +129,279 @@ function assertArchiveFailureStillRestoresDependentServices() {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertMultipartUploadRetriesAndVerifiesRemoteLength() {
|
||||
const root = path.join(tmpRoot, 'multipart-success');
|
||||
const archivePath = path.join(root, 'backup.tar.gz');
|
||||
const partSizeBytes = 100 * 1024;
|
||||
const payload = Buffer.concat([
|
||||
Buffer.alloc(partSizeBytes, 'a'),
|
||||
Buffer.alloc(partSizeBytes, 'b'),
|
||||
Buffer.alloc(17, 'c'),
|
||||
]);
|
||||
mkdirSync(root, {recursive: true});
|
||||
writeFileSync(archivePath, payload);
|
||||
|
||||
const requests = [];
|
||||
const retryDelays = [];
|
||||
let firstPartAttempts = 0;
|
||||
const uploadId = 'upload+/= id';
|
||||
const fetchImpl = async (url, options) => {
|
||||
const body = await readRequestBody(options.body);
|
||||
requests.push({url, method: options.method, headers: options.headers, body});
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (options.method === 'POST' && parsedUrl.search === '?uploads') {
|
||||
return new Response(`<InitiateMultipartUploadResult><UploadId>${uploadId}</UploadId></InitiateMultipartUploadResult>`, {status: 200});
|
||||
}
|
||||
if (options.method === 'PUT') {
|
||||
const partNumber = Number(parsedUrl.searchParams.get('partNumber'));
|
||||
if (partNumber === 1) {
|
||||
firstPartAttempts += 1;
|
||||
if (firstPartAttempts === 1) {
|
||||
return new Response('<Error><Code>ServiceUnavailable</Code></Error>', {status: 503});
|
||||
}
|
||||
}
|
||||
return new Response('', {status: 200, headers: {etag: `"etag-${partNumber}"`}});
|
||||
}
|
||||
if (options.method === 'POST' && parsedUrl.searchParams.has('uploadId')) {
|
||||
return new Response('<CompleteMultipartUploadResult/>', {status: 200, headers: {etag: '"complete-etag"'}});
|
||||
}
|
||||
if (options.method === 'HEAD') {
|
||||
return new Response(null, {status: 200, headers: {'content-length': String(payload.length)}});
|
||||
}
|
||||
throw new Error(`unexpected request: ${options.method} ${url}`);
|
||||
};
|
||||
|
||||
const result = await uploadArchive({
|
||||
archivePath,
|
||||
bucket: 'genarrative-test',
|
||||
endpoint: 'oss-cn-shanghai.aliyuncs.com',
|
||||
objectKey: 'database-backups/test/backup.tar.gz',
|
||||
accessKeyId: 'test-access-key',
|
||||
accessKeySecret: 'test-access-secret',
|
||||
partSizeBytes,
|
||||
maxAttempts: 3,
|
||||
retryBaseDelayMs: 1,
|
||||
retryMaxDelayMs: 1,
|
||||
fetchImpl,
|
||||
nowFn: () => new Date('2026-07-13T10:20:30.000Z'),
|
||||
sleepImpl: async (delayMs) => retryDelays.push(delayMs),
|
||||
randomFn: () => 0,
|
||||
});
|
||||
|
||||
assertEqual(result.uploadMode, 'multipart', '上传结果必须记录 multipart 模式。');
|
||||
assertEqual(result.partCount, 3, 'multipart 应按配置大小切成三段。');
|
||||
assertEqual(result.contentLength, payload.length, '上传结果应保留完整归档长度。');
|
||||
assertEqual(result.etag, 'complete-etag', '上传结果应保留 CompleteMultipartUpload ETag。');
|
||||
assertEqual(firstPartAttempts, 2, '503 后应仅重试失败的第一段。');
|
||||
assertEqual(retryDelays.length, 1, '一次可重试失败应触发一次退避。');
|
||||
|
||||
const initiateRequest = requests[0];
|
||||
assertTrue(initiateRequest.url.endsWith('?uploads'), 'InitiateMultipartUpload URL 必须使用裸 uploads 参数。');
|
||||
assertTrue(!initiateRequest.url.endsWith('?uploads='), 'InitiateMultipartUpload URL 不能把裸参数写成 uploads=。');
|
||||
const firstPartRequests = requests.filter(({method, url}) => method === 'PUT' && new URL(url).searchParams.get('partNumber') === '1');
|
||||
assertEqual(firstPartRequests.length, 2, '第一段应产生原请求和一次重试。');
|
||||
assertBufferEqual(firstPartRequests[0].body, payload.subarray(0, partSizeBytes), '第一段原请求内容必须完整。');
|
||||
assertBufferEqual(firstPartRequests[1].body, payload.subarray(0, partSizeBytes), '第一段重试必须重新创建并完整读取 stream。');
|
||||
assertTrue(
|
||||
firstPartRequests[0].url.includes('?partNumber=1&uploadId=upload%2B%2F%3D%20id'),
|
||||
'UploadPart URL 必须使用排序并编码后的 canonical query。',
|
||||
);
|
||||
|
||||
const completeRequest = requests.find(({method, url}) => method === 'POST' && new URL(url).searchParams.has('uploadId'));
|
||||
assertIncludes(completeRequest?.body.toString('utf8') ?? '', '<PartNumber>1</PartNumber><ETag>"etag-1"</ETag>', 'Complete XML 应包含第一段 ETag。');
|
||||
assertIncludes(completeRequest?.body.toString('utf8') ?? '', '<PartNumber>3</PartNumber><ETag>"etag-3"</ETag>', 'Complete XML 应包含最后一段 ETag。');
|
||||
assertTrue(requests.some(({method}) => method === 'HEAD'), 'Complete 后必须执行签名 HEAD 验证。');
|
||||
for (const request of requests) {
|
||||
assertTrue(String(request.headers.authorization ?? '').startsWith('OSS4-HMAC-SHA256 '), `${request.method} 请求必须携带 V4 Authorization。`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertHeadLengthMismatchAbortsMultipartUpload() {
|
||||
const root = path.join(tmpRoot, 'multipart-head-mismatch');
|
||||
const archivePath = path.join(root, 'backup.tar.gz');
|
||||
const partSizeBytes = 100 * 1024;
|
||||
const payload = Buffer.alloc(partSizeBytes + 1, 'x');
|
||||
mkdirSync(root, {recursive: true});
|
||||
writeFileSync(archivePath, payload);
|
||||
|
||||
const requests = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
await readRequestBody(options.body);
|
||||
requests.push({url, method: options.method});
|
||||
const parsedUrl = new URL(url);
|
||||
if (options.method === 'POST' && parsedUrl.search === '?uploads') {
|
||||
return new Response('<InitiateMultipartUploadResult><UploadId>mismatch-upload</UploadId></InitiateMultipartUploadResult>', {status: 200});
|
||||
}
|
||||
if (options.method === 'PUT') {
|
||||
return new Response('', {status: 200, headers: {etag: `"etag-${parsedUrl.searchParams.get('partNumber')}"`}});
|
||||
}
|
||||
if (options.method === 'POST') {
|
||||
return new Response('<CompleteMultipartUploadResult/>', {status: 200, headers: {etag: '"complete-etag"'}});
|
||||
}
|
||||
if (options.method === 'HEAD') {
|
||||
return new Response(null, {status: 200, headers: {'content-length': String(payload.length - 1)}});
|
||||
}
|
||||
if (options.method === 'DELETE') {
|
||||
return new Response(null, {status: 204});
|
||||
}
|
||||
throw new Error(`unexpected request: ${options.method} ${url}`);
|
||||
};
|
||||
|
||||
let uploadError = null;
|
||||
try {
|
||||
await uploadArchive({
|
||||
archivePath,
|
||||
bucket: 'genarrative-test',
|
||||
endpoint: 'oss-cn-shanghai.aliyuncs.com',
|
||||
objectKey: 'database-backups/test/mismatch.tar.gz',
|
||||
accessKeyId: 'test-access-key',
|
||||
accessKeySecret: 'test-access-secret',
|
||||
partSizeBytes,
|
||||
maxAttempts: 2,
|
||||
retryBaseDelayMs: 1,
|
||||
retryMaxDelayMs: 1,
|
||||
fetchImpl,
|
||||
nowFn: () => new Date('2026-07-13T10:20:30.000Z'),
|
||||
sleepImpl: async () => {},
|
||||
randomFn: () => 0,
|
||||
});
|
||||
} catch (error) {
|
||||
uploadError = error;
|
||||
}
|
||||
|
||||
assertTrue(uploadError instanceof Error, 'HEAD 长度不一致时上传必须失败。');
|
||||
assertIncludes(uploadError?.message ?? '', 'HEAD 验证长度不一致', 'HEAD 长度不一致错误应保留本地和远端长度。');
|
||||
const abortRequest = requests.find(({method}) => method === 'DELETE');
|
||||
assertTrue(Boolean(abortRequest), 'HEAD 长度不一致后必须 best-effort AbortMultipartUpload。');
|
||||
assertTrue(abortRequest?.url.endsWith('?uploadId=mismatch-upload'), 'AbortMultipartUpload 必须携带同一 uploadId。');
|
||||
}
|
||||
|
||||
async function assertMissingPartEtagAbortsMultipartUpload() {
|
||||
const root = path.join(tmpRoot, 'multipart-missing-etag');
|
||||
const archivePath = path.join(root, 'backup.tar.gz');
|
||||
mkdirSync(root, {recursive: true});
|
||||
writeFileSync(archivePath, Buffer.alloc(100 * 1024, 'e'));
|
||||
|
||||
const requests = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
await readRequestBody(options.body);
|
||||
requests.push({url, method: options.method});
|
||||
const parsedUrl = new URL(url);
|
||||
if (options.method === 'POST' && parsedUrl.search === '?uploads') {
|
||||
return new Response('<InitiateMultipartUploadResult><UploadId>missing-etag-upload</UploadId></InitiateMultipartUploadResult>', {status: 200});
|
||||
}
|
||||
if (options.method === 'PUT') {
|
||||
return new Response('', {status: 200});
|
||||
}
|
||||
if (options.method === 'DELETE') {
|
||||
return new Response(null, {status: 204});
|
||||
}
|
||||
throw new Error(`unexpected request: ${options.method} ${url}`);
|
||||
};
|
||||
|
||||
let uploadError = null;
|
||||
try {
|
||||
await uploadArchive({
|
||||
archivePath,
|
||||
bucket: 'genarrative-test',
|
||||
endpoint: 'oss-cn-shanghai.aliyuncs.com',
|
||||
objectKey: 'database-backups/test/missing-etag.tar.gz',
|
||||
accessKeyId: 'test-access-key',
|
||||
accessKeySecret: 'test-access-secret',
|
||||
partSizeBytes: 100 * 1024,
|
||||
maxAttempts: 1,
|
||||
fetchImpl,
|
||||
nowFn: () => new Date('2026-07-13T10:20:30.000Z'),
|
||||
sleepImpl: async () => {},
|
||||
randomFn: () => 0,
|
||||
});
|
||||
} catch (error) {
|
||||
uploadError = error;
|
||||
}
|
||||
|
||||
assertIncludes(uploadError?.message ?? '', '响应缺少 ETag', 'UploadPart 缺少 ETag 时必须失败。');
|
||||
assertTrue(
|
||||
requests.some(({method, url}) => method === 'DELETE' && url.endsWith('?uploadId=missing-etag-upload')),
|
||||
'UploadPart 缺少 ETag 后必须 AbortMultipartUpload。',
|
||||
);
|
||||
}
|
||||
|
||||
async function assertCompleteResponseAmbiguityUsesHeadVerification() {
|
||||
const root = path.join(tmpRoot, 'multipart-complete-ambiguity');
|
||||
const archivePath = path.join(root, 'backup.tar.gz');
|
||||
const payload = Buffer.alloc(100 * 1024, 'c');
|
||||
mkdirSync(root, {recursive: true});
|
||||
writeFileSync(archivePath, payload);
|
||||
|
||||
const requests = [];
|
||||
let completeAttempts = 0;
|
||||
const fetchImpl = async (url, options) => {
|
||||
await readRequestBody(options.body);
|
||||
requests.push({url, method: options.method});
|
||||
const parsedUrl = new URL(url);
|
||||
if (options.method === 'POST' && parsedUrl.search === '?uploads') {
|
||||
return new Response('<InitiateMultipartUploadResult><UploadId>ambiguous-upload</UploadId></InitiateMultipartUploadResult>', {status: 200});
|
||||
}
|
||||
if (options.method === 'PUT') {
|
||||
return new Response('', {status: 200, headers: {etag: '"part-etag"'}});
|
||||
}
|
||||
if (options.method === 'POST' && parsedUrl.searchParams.has('uploadId')) {
|
||||
completeAttempts += 1;
|
||||
if (completeAttempts === 1) {
|
||||
throw new TypeError('socket closed after remote complete');
|
||||
}
|
||||
return new Response('<Error><Code>NoSuchUpload</Code></Error>', {status: 404});
|
||||
}
|
||||
if (options.method === 'HEAD') {
|
||||
return new Response(null, {status: 200, headers: {'content-length': String(payload.length)}});
|
||||
}
|
||||
if (options.method === 'DELETE') {
|
||||
return new Response(null, {status: 204});
|
||||
}
|
||||
throw new Error(`unexpected request: ${options.method} ${url}`);
|
||||
};
|
||||
|
||||
const result = await uploadArchive({
|
||||
archivePath,
|
||||
bucket: 'genarrative-test',
|
||||
endpoint: 'oss-cn-shanghai.aliyuncs.com',
|
||||
objectKey: 'database-backups/test/complete-ambiguity.tar.gz',
|
||||
accessKeyId: 'test-access-key',
|
||||
accessKeySecret: 'test-access-secret',
|
||||
partSizeBytes: 100 * 1024,
|
||||
maxAttempts: 2,
|
||||
retryBaseDelayMs: 1,
|
||||
retryMaxDelayMs: 1,
|
||||
fetchImpl,
|
||||
nowFn: () => new Date('2026-07-13T10:20:30.000Z'),
|
||||
sleepImpl: async () => {},
|
||||
randomFn: () => 0,
|
||||
});
|
||||
|
||||
assertEqual(completeAttempts, 2, 'Complete 网络错误后应按策略重试。');
|
||||
assertEqual(result.contentLength, payload.length, 'Complete 结果不确定时应以 HEAD 长度验真收口。');
|
||||
assertTrue(requests.some(({method}) => method === 'HEAD'), 'Complete 结果不确定时必须执行 HEAD 验真。');
|
||||
assertTrue(!requests.some(({method}) => method === 'DELETE'), 'HEAD 已证实对象完整时不得 Abort 已完成上传。');
|
||||
}
|
||||
|
||||
async function readRequestBody(body) {
|
||||
if (body === undefined || body === null) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
return Buffer.from(body);
|
||||
}
|
||||
if (Buffer.isBuffer(body)) {
|
||||
return body;
|
||||
}
|
||||
const chunks = [];
|
||||
for await (const chunk of body) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function createFixture(name) {
|
||||
const root = path.join(tmpRoot, name);
|
||||
const binDir = path.join(root, 'bin');
|
||||
@@ -160,6 +484,30 @@ function assertIncludes(content, expected, reason) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, reason) {
|
||||
if (actual !== expected) {
|
||||
failures.push(`${reason} 预期: ${String(expected)},实际: ${String(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotEqual(actual, expected, reason) {
|
||||
if (actual === expected) {
|
||||
failures.push(`${reason} 两者均为: ${String(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTrue(condition, reason) {
|
||||
if (!condition) {
|
||||
failures.push(reason);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBufferEqual(actual, expected, reason) {
|
||||
if (!Buffer.isBuffer(actual) || !actual.equals(expected)) {
|
||||
failures.push(`${reason} 预期 ${expected.length} bytes,实际 ${actual?.length ?? '<missing>'} bytes。`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFileMissing(filePath, reason) {
|
||||
if (existsSync(filePath)) {
|
||||
failures.push(`${reason} 实际存在: ${filePath}\n${readFile(filePath)}`);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const failures = [];
|
||||
const requestedFiles = [];
|
||||
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
if (process.argv[index] !== '--file' || !process.argv[index + 1]) {
|
||||
failures.push(`未知或不完整参数: ${process.argv[index]}`);
|
||||
continue;
|
||||
}
|
||||
requestedFiles.push(path.resolve(process.argv[index + 1]));
|
||||
index += 1;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
function validateDefaultPage(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
fail(`默认维护页不存在: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
if (!source.includes('服务维护中')) {
|
||||
fail(`${filePath} 必须保留无日期的“服务维护中”默认文案。`);
|
||||
}
|
||||
for (const [pattern, label] of [
|
||||
[/(?:今天|今晚|明天|昨天|昨日)/u, '相对日期'],
|
||||
[/(?:20\d{2}[-/.年]\d{1,2}(?:[-/.月]\d{1,2}日?)?|\d{1,2}月\d{1,2}日)/u, '具体日期'],
|
||||
[/(?:[01]?\d|2[0-3]):[0-5]\d/u, '具体维护时间'],
|
||||
]) {
|
||||
if (pattern.test(source)) {
|
||||
fail(`${filePath} 不能包含${label};临时公告必须使用运行态覆盖页。`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runScript(scriptPath, args, env) {
|
||||
return spawnSync('bash', [scriptPath, ...args], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function validateRuntimePageLifecycle() {
|
||||
const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'genarrative-maintenance-'));
|
||||
const markerFile = path.join(tempRoot, 'state', 'enabled');
|
||||
const runtimePageFile = path.join(tempRoot, 'state', 'page.html');
|
||||
const sourcePageFile = path.join(tempRoot, 'announcement.html');
|
||||
const onScript = path.join(repoRoot, 'scripts/deploy/maintenance-on.sh');
|
||||
const offScript = path.join(repoRoot, 'scripts/deploy/maintenance-off.sh');
|
||||
const env = {
|
||||
GENARRATIVE_MAINTENANCE_FILE: markerFile,
|
||||
GENARRATIVE_MAINTENANCE_PAGE_FILE: runtimePageFile,
|
||||
};
|
||||
|
||||
try {
|
||||
const announcement = '<!doctype html><title>planned maintenance</title>\n';
|
||||
writeFileSync(sourcePageFile, announcement);
|
||||
|
||||
const enable = runScript(
|
||||
onScript,
|
||||
['--page-file', sourcePageFile, 'planned maintenance'],
|
||||
env,
|
||||
);
|
||||
if (enable.status !== 0) {
|
||||
fail(`maintenance-on --page-file 执行失败: ${enable.stderr || enable.stdout}`);
|
||||
return;
|
||||
}
|
||||
if (!existsSync(markerFile)) {
|
||||
fail('maintenance-on --page-file 必须创建维护 marker。');
|
||||
}
|
||||
if (!existsSync(runtimePageFile)) {
|
||||
fail('maintenance-on --page-file 必须原子安装运行态公告页。');
|
||||
} else {
|
||||
if (readFileSync(runtimePageFile, 'utf8') !== announcement) {
|
||||
fail('运行态公告页内容与输入文件不一致。');
|
||||
}
|
||||
if ((statSync(runtimePageFile).mode & 0o777) !== 0o644) {
|
||||
fail('运行态公告页权限必须为 0644。');
|
||||
}
|
||||
}
|
||||
|
||||
const nestedEnable = runScript(onScript, ['api deploy'], env);
|
||||
if (nestedEnable.status !== 0 || !existsSync(runtimePageFile)) {
|
||||
fail('同一维护窗口内的后续 maintenance-on 必须保留已安装公告页。');
|
||||
}
|
||||
|
||||
const disable = runScript(offScript, [], env);
|
||||
if (disable.status !== 0) {
|
||||
fail(`maintenance-off 执行失败: ${disable.stderr || disable.stdout}`);
|
||||
}
|
||||
if (existsSync(markerFile) || existsSync(runtimePageFile)) {
|
||||
fail('maintenance-off 必须同时清理 marker 和运行态公告页。');
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(runtimePageFile), { recursive: true });
|
||||
writeFileSync(runtimePageFile, announcement);
|
||||
chmodSync(runtimePageFile, 0o644);
|
||||
const genericEnable = runScript(onScript, ['generic maintenance'], env);
|
||||
if (genericEnable.status !== 0) {
|
||||
fail(`通用 maintenance-on 执行失败: ${genericEnable.stderr || genericEnable.stdout}`);
|
||||
}
|
||||
if (existsSync(runtimePageFile)) {
|
||||
fail('新维护窗口未提供 --page-file 时必须清理残留公告页。');
|
||||
}
|
||||
runScript(offScript, [], env);
|
||||
|
||||
const missingPage = runScript(
|
||||
onScript,
|
||||
['--page-file', path.join(tempRoot, 'missing.html')],
|
||||
env,
|
||||
);
|
||||
if (missingPage.status === 0 || existsSync(markerFile)) {
|
||||
fail('不存在的 --page-file 必须在创建 marker 前失败。');
|
||||
}
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function validateGatewayConfiguration() {
|
||||
const nginxSnippet = readFileSync(
|
||||
path.join(repoRoot, 'deploy/nginx/snippets/genarrative-maintenance.conf'),
|
||||
'utf8',
|
||||
);
|
||||
for (const expected of [
|
||||
'root /var/lib/genarrative/maintenance;',
|
||||
'try_files /page.html @genarrative_default_maintenance;',
|
||||
'location @genarrative_default_maintenance',
|
||||
'root /srv/genarrative/web;',
|
||||
]) {
|
||||
if (!nginxSnippet.includes(expected)) {
|
||||
fail(`Nginx 维护页配置缺少运行态覆盖约束: ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pingoraSource = readFileSync(
|
||||
path.join(repoRoot, 'server-rs/crates/pingora-gateway/src/main.rs'),
|
||||
'utf8',
|
||||
);
|
||||
for (const expected of [
|
||||
'GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE',
|
||||
'maintenance_page_file',
|
||||
]) {
|
||||
if (!pingoraSource.includes(expected)) {
|
||||
fail(`Pingora 维护页配置缺少运行态覆盖约束: ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
const releaseBuildScript = readFileSync(
|
||||
path.join(repoRoot, 'scripts/build-production-release.sh'),
|
||||
'utf8',
|
||||
);
|
||||
if (
|
||||
!releaseBuildScript.includes(
|
||||
'node scripts/check-maintenance-page.mjs --file "${WEB_DIR}/maintenance.html"',
|
||||
)
|
||||
) {
|
||||
fail('生产 Web 发布包构建必须校验最终 maintenance.html 不含临时公告。');
|
||||
}
|
||||
}
|
||||
|
||||
for (const filePath of
|
||||
requestedFiles.length > 0
|
||||
? requestedFiles
|
||||
: [path.join(repoRoot, 'public/maintenance.html')]) {
|
||||
validateDefaultPage(filePath);
|
||||
}
|
||||
|
||||
if (requestedFiles.length === 0) {
|
||||
validateRuntimePageLifecycle();
|
||||
validateGatewayConfiguration();
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:maintenance-page] FAILED');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[check:maintenance-page] 通过');
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const APP_PAGE_ROUTES_PATH = 'src/routing/appPageRoutes.ts';
|
||||
const APP_ROUTES_PATH = 'src/routing/appRoutes.tsx';
|
||||
const COMPATIBILITY_ROUTES = ['/creation/rpg/agent'];
|
||||
const NGINX_PATHS = [
|
||||
'deploy/nginx/genarrative.conf',
|
||||
'deploy/nginx/genarrative-dev-http.conf',
|
||||
'deploy/container/nginx.conf',
|
||||
];
|
||||
const MAINTENANCE_NGINX_PATHS = [
|
||||
'deploy/nginx/genarrative.conf',
|
||||
'deploy/nginx/genarrative-dev-http.conf',
|
||||
];
|
||||
const MAINTENANCE_SNIPPET_PATH =
|
||||
'deploy/nginx/snippets/genarrative-maintenance.conf';
|
||||
const SPA_BLOCK_START = '# BEGIN GENARRATIVE MAIN SPA ROUTES';
|
||||
const SPA_BLOCK_END = '# END GENARRATIVE MAIN SPA ROUTES';
|
||||
const UNKNOWN_ROUTE_SAMPLES = [
|
||||
'/unknown-root',
|
||||
'/creation/not-exist',
|
||||
'/runtime/not-exist',
|
||||
'/puzzle/not-exist',
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
function extractSourceBlock(source, pattern, label) {
|
||||
const match = source.match(pattern);
|
||||
if (!match) {
|
||||
fail(`${label} 未找到。`);
|
||||
return '';
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function collectExpectedMainSpaRoutes() {
|
||||
const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8');
|
||||
const appRoutes = readFileSync(APP_ROUTES_PATH, 'utf8');
|
||||
const stageEntries = extractSourceBlock(
|
||||
appPageRoutes,
|
||||
/const STAGE_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u,
|
||||
`${APP_PAGE_ROUTES_PATH} STAGE_ROUTE_ENTRIES`,
|
||||
);
|
||||
const runtimeEntries = extractSourceBlock(
|
||||
appPageRoutes,
|
||||
/export const APP_RUNTIME_ROUTES[^=]*= \{([\s\S]*?)\n\};/u,
|
||||
`${APP_PAGE_ROUTES_PATH} APP_RUNTIME_ROUTES`,
|
||||
);
|
||||
|
||||
const routes = [
|
||||
...Array.from(
|
||||
stageEntries.matchAll(/\[\s*'[^']+'\s*,\s*'([^']+)'\s*\]/gu),
|
||||
(match) => match[1],
|
||||
),
|
||||
...Array.from(
|
||||
runtimeEntries.matchAll(/'[^']+'\s*:\s*'([^']+)'/gu),
|
||||
(match) => match[1],
|
||||
),
|
||||
...Array.from(
|
||||
appRoutes.matchAll(/normalizedPath === '([^']+)'/gu),
|
||||
(match) => match[1],
|
||||
),
|
||||
...COMPATIBILITY_ROUTES,
|
||||
];
|
||||
|
||||
const uniqueRoutes = [...new Set(routes)].sort();
|
||||
if (uniqueRoutes.length === 0) {
|
||||
fail('未从前端路由源提取到主站 SPA 路由。');
|
||||
}
|
||||
for (const route of uniqueRoutes) {
|
||||
if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route)) {
|
||||
fail(`前端路由源包含门禁暂不支持的路径格式: ${route}`);
|
||||
}
|
||||
}
|
||||
return uniqueRoutes;
|
||||
}
|
||||
|
||||
function compareRouteSets(actualRoutes, expectedRoutes, label) {
|
||||
const actual = new Set(actualRoutes);
|
||||
const expected = new Set(expectedRoutes);
|
||||
const missing = expectedRoutes.filter((route) => !actual.has(route));
|
||||
const extra = actualRoutes.filter((route) => !expected.has(route));
|
||||
if (missing.length > 0) {
|
||||
fail(`${label} 缺少 SPA 路由: ${missing.join(', ')}`);
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
fail(`${label} 包含非当前路由: ${extra.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNginxRoutes(nginxPath, expectedRoutes) {
|
||||
const source = readFileSync(nginxPath, 'utf8');
|
||||
const blockStart = source.indexOf(SPA_BLOCK_START);
|
||||
const blockEnd = source.indexOf(SPA_BLOCK_END);
|
||||
if (blockStart < 0 || blockEnd <= blockStart) {
|
||||
fail(`${nginxPath} 缺少完整 SPA allowlist 标记。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = source.slice(blockStart, blockEnd + SPA_BLOCK_END.length);
|
||||
if (!/location\s+=\s+\/\s*\{/u.test(block)) {
|
||||
fail(`${nginxPath} SPA allowlist 缺少根路径精确 location。`);
|
||||
}
|
||||
if (!block.includes('try_files /index.html =404;')) {
|
||||
fail(`${nginxPath} 根路径没有精确回退 index.html。`);
|
||||
}
|
||||
if (!block.includes('try_files $uri /index.html =404;')) {
|
||||
fail(`${nginxPath} SPA allowlist 没有精确回退 index.html。`);
|
||||
}
|
||||
|
||||
const regexMatch = block.match(/location\s+~\*\s+"([^"]+)"\s*\{/u);
|
||||
if (!regexMatch) {
|
||||
fail(`${nginxPath} 缺少大小写不敏感的 SPA allowlist regex location。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nginxPattern = regexMatch[1];
|
||||
const alternativesMatch = nginxPattern.match(/^\^\/\(\?:(.+)\)\/\?\$$/u);
|
||||
if (!alternativesMatch) {
|
||||
fail(`${nginxPath} SPA allowlist 必须锚定完整路径并允许一个尾部斜杠。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredRoutes = [
|
||||
'/',
|
||||
...alternativesMatch[1].split('|').map((route) => `/${route}`),
|
||||
].sort();
|
||||
compareRouteSets(configuredRoutes, expectedRoutes, nginxPath);
|
||||
|
||||
const matcher = new RegExp(nginxPattern, 'iu');
|
||||
for (const route of expectedRoutes.filter((candidate) => candidate !== '/')) {
|
||||
if (!matcher.test(route)) {
|
||||
fail(`${nginxPath} SPA allowlist 未匹配完整路径: ${route}`);
|
||||
}
|
||||
if (!matcher.test(`${route.toUpperCase()}/`)) {
|
||||
fail(`${nginxPath} SPA allowlist 未允许大小写差异和尾部斜杠: ${route}`);
|
||||
}
|
||||
}
|
||||
for (const route of UNKNOWN_ROUTE_SAMPLES) {
|
||||
if (matcher.test(route) || matcher.test(`${route}/`)) {
|
||||
fail(`${nginxPath} SPA allowlist 错误接收未知路径: ${route}`);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length);
|
||||
if (!defaultLocation.includes('try_files $uri $uri/ =404;')) {
|
||||
fail(
|
||||
`${nginxPath} 未命中 SPA allowlist 的路径必须只读真实静态文件并返回 404。`,
|
||||
);
|
||||
}
|
||||
if (defaultLocation.includes('try_files $uri $uri/ /index.html;')) {
|
||||
fail(`${nginxPath} 默认 location 仍存在全路径 SPA fallback。`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateMaintenanceInternalBypass() {
|
||||
const snippet = readFileSync(MAINTENANCE_SNIPPET_PATH, 'utf8');
|
||||
const internalBypassPattern =
|
||||
/set \$genarrative_maintenance 0;\s*if \(-f \/var\/lib\/genarrative\/maintenance\/enabled\) \{\s*set \$genarrative_maintenance 1;\s*\}\s*if \(\$genarrative_internal_client\) \{\s*set \$genarrative_maintenance 0;\s*\}/u;
|
||||
if (!internalBypassPattern.test(snippet)) {
|
||||
fail(
|
||||
`${MAINTENANCE_SNIPPET_PATH} 必须在读取维护 marker 后为真实内网来源清除全站维护状态。`,
|
||||
);
|
||||
}
|
||||
if (snippet.includes('$genarrative_admin_maintenance')) {
|
||||
fail(`${MAINTENANCE_SNIPPET_PATH} 不应保留仅后台使用的维护变量。`);
|
||||
}
|
||||
for (const fragment of [
|
||||
'location = /404.html {',
|
||||
'if ($http_accept !~* "text/html") {',
|
||||
'try_files /404.html =404;',
|
||||
'add_header Cache-Control "no-store";',
|
||||
'internal;',
|
||||
]) {
|
||||
if (!snippet.includes(fragment)) {
|
||||
fail(`${MAINTENANCE_SNIPPET_PATH} 缺少品牌 404 页面约束: ${fragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const nginxPath of MAINTENANCE_NGINX_PATHS) {
|
||||
const source = readFileSync(nginxPath, 'utf8');
|
||||
for (const fragment of [
|
||||
'geo $remote_addr $genarrative_internal_client {',
|
||||
'127.0.0.0/8 1;',
|
||||
'10.0.0.0/8 1;',
|
||||
'172.16.0.0/12 1;',
|
||||
'192.168.0.0/16 1;',
|
||||
'169.254.0.0/16 1;',
|
||||
'::1 1;',
|
||||
'fc00::/7 1;',
|
||||
'fe80::/10 1;',
|
||||
]) {
|
||||
if (!source.includes(fragment)) {
|
||||
fail(`${nginxPath} 缺少内网来源识别片段: ${fragment}`);
|
||||
}
|
||||
}
|
||||
if (source.includes('$genarrative_admin_maintenance')) {
|
||||
fail(`${nginxPath} 的维护入口必须统一使用全站维护变量。`);
|
||||
}
|
||||
if (!source.includes('error_page 404 /404.html;')) {
|
||||
fail(`${nginxPath} 的 Web 未知路由必须返回品牌 404 页面。`);
|
||||
}
|
||||
const maintenanceChecks =
|
||||
source.match(/if \(\$genarrative_[a-z_]*maintenance\)/gu) ?? [];
|
||||
if (maintenanceChecks.length === 0) {
|
||||
fail(`${nginxPath} 缺少维护状态判断。`);
|
||||
}
|
||||
for (const maintenanceCheck of maintenanceChecks) {
|
||||
if (maintenanceCheck !== 'if ($genarrative_maintenance)') {
|
||||
fail(
|
||||
`${nginxPath} 存在未统一到全站维护变量的判断: ${maintenanceCheck}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes();
|
||||
|
||||
for (const nginxPath of NGINX_PATHS) {
|
||||
validateNginxRoutes(nginxPath, expectedMainSpaRoutes);
|
||||
}
|
||||
validateMaintenanceInternalBypass();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:nginx-spa-routes] FAILED');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`,
|
||||
);
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const failures = [];
|
||||
@@ -51,6 +51,7 @@ async function main() {
|
||||
const webRoot = path.join(tempRoot, 'web');
|
||||
const acmeRoot = path.join(tempRoot, 'acme');
|
||||
const maintenanceFile = path.join(tempRoot, 'maintenance', 'enabled');
|
||||
const maintenancePageFile = path.join(tempRoot, 'maintenance', 'page.html');
|
||||
const accessLogFile = path.join(tempRoot, 'logs', 'pingora.access.log');
|
||||
|
||||
await prepareStaticRoots(webRoot, acmeRoot);
|
||||
@@ -158,6 +159,7 @@ async function main() {
|
||||
GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT: webRoot,
|
||||
GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT: acmeRoot,
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE: maintenanceFile,
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE: maintenancePageFile,
|
||||
GENARRATIVE_PINGORA_GATEWAY_ACCESS_LOG_FILE: accessLogFile,
|
||||
GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN: probeToken,
|
||||
GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO: 'https',
|
||||
@@ -208,6 +210,7 @@ async function main() {
|
||||
redirectBaseUrl,
|
||||
probeToken,
|
||||
maintenanceFile,
|
||||
maintenancePageFile,
|
||||
accessLogFile,
|
||||
api,
|
||||
spacetime,
|
||||
@@ -348,8 +351,9 @@ async function prepareStaticRoots(webRoot, acmeRoot) {
|
||||
);
|
||||
await writeFile(
|
||||
path.join(webRoot, 'maintenance.html'),
|
||||
'<main>maintenance</main>',
|
||||
'<main>default-maintenance</main>',
|
||||
);
|
||||
await writeFile(path.join(webRoot, '404.html'), '<main>not-found-page</main>');
|
||||
await writeFile(
|
||||
path.join(acmeRoot, '.well-known', 'acme-challenge', 'token'),
|
||||
'acme-token',
|
||||
@@ -550,6 +554,7 @@ async function runSmokeCases(
|
||||
redirectBaseUrl,
|
||||
probeToken,
|
||||
maintenanceFile,
|
||||
maintenancePageFile,
|
||||
accessLogFile,
|
||||
api,
|
||||
spacetime,
|
||||
@@ -568,15 +573,51 @@ async function runSmokeCases(
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/some/deep/link',
|
||||
'/creation/puzzle/result',
|
||||
200,
|
||||
'site-shell',
|
||||
'主站深链回退 index.html',
|
||||
'主站 allowlist 深链回退 index.html',
|
||||
{
|
||||
validate: (response) =>
|
||||
response.headers['cache-control'] === 'no-cache',
|
||||
},
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/CREATION/PUZZLE/RESULT/',
|
||||
200,
|
||||
'site-shell',
|
||||
'主站 allowlist 允许大小写差异和尾部斜杠',
|
||||
);
|
||||
for (const unknownPath of [
|
||||
'/some/deep/link',
|
||||
'/creation/not-exist',
|
||||
'/runtime/not-exist',
|
||||
'/puzzle/not-exist',
|
||||
]) {
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
unknownPath,
|
||||
404,
|
||||
'',
|
||||
`主站未知路径返回真实 404: ${unknownPath}`,
|
||||
);
|
||||
}
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/some/browser/navigation',
|
||||
404,
|
||||
'not-found-page',
|
||||
'浏览器导航未知路径返回品牌 404 页面',
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
},
|
||||
validate: (response) =>
|
||||
response.headers['cache-control'] === 'no-store' &&
|
||||
response.headers['content-type']?.startsWith('text/html'),
|
||||
},
|
||||
);
|
||||
await expectHttp(baseUrl, '/admin', 301, '', '/admin 301 到 /admin/', {
|
||||
validate: (response) => response.headers.location === '/admin/',
|
||||
});
|
||||
@@ -1103,7 +1144,13 @@ async function runSmokeCases(
|
||||
]);
|
||||
|
||||
await mkdir(path.dirname(maintenanceFile), { recursive: true });
|
||||
await writeFile(maintenancePageFile, '<main>runtime-maintenance</main>');
|
||||
await writeFile(maintenanceFile, 'enabled');
|
||||
const publicClientHeaders = {
|
||||
'X-Real-IP': '203.0.113.50',
|
||||
'X-Forwarded-For': '192.168.35.50',
|
||||
};
|
||||
const internalClientHeaders = { 'X-Real-IP': '192.168.35.50' };
|
||||
const giteaRequestsBeforeMaintenance = gitea.state.requests.length;
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
@@ -1114,6 +1161,7 @@ async function runSmokeCases(
|
||||
{
|
||||
headers: {
|
||||
Host: 'git.genarrative.world',
|
||||
...publicClientHeaders,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1121,14 +1169,82 @@ async function runSmokeCases(
|
||||
gitea.state.requests.length === giteaRequestsBeforeMaintenance + 1,
|
||||
'维护模式 Gitea Host 请求没有打到 Gitea mock',
|
||||
);
|
||||
for (const [path, bodyNeedle, label] of [
|
||||
['/', 'runtime-maintenance', '公网主站页面'],
|
||||
['/api/creation-entry/config', 'MAINTENANCE', '公网普通 API'],
|
||||
['/v1/identity', 'runtime-maintenance', '公网 SpacetimeDB 路由'],
|
||||
['/admin/settings', 'runtime-maintenance', '公网后台页面'],
|
||||
['/admin/assets/admin.js', 'runtime-maintenance', '公网后台静态资源'],
|
||||
['/admin/api/users', 'MAINTENANCE', '公网后台 API'],
|
||||
]) {
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
path,
|
||||
503,
|
||||
bodyNeedle,
|
||||
`维护模式继续拦截${label}`,
|
||||
{
|
||||
headers: publicClientHeaders,
|
||||
},
|
||||
);
|
||||
}
|
||||
await rm(maintenancePageFile, { force: true });
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/',
|
||||
503,
|
||||
'default-maintenance',
|
||||
'运行态公告缺失时回退 Web 制品默认维护页',
|
||||
{ headers: publicClientHeaders },
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/',
|
||||
200,
|
||||
'site-shell',
|
||||
'维护模式允许内网主站页面',
|
||||
{ headers: internalClientHeaders },
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/api/creation-entry/config',
|
||||
503,
|
||||
'MAINTENANCE',
|
||||
'维护模式 API JSON',
|
||||
200,
|
||||
'"upstream":"api"',
|
||||
'维护模式允许内网普通 API',
|
||||
{ headers: internalClientHeaders },
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/v1/identity',
|
||||
200,
|
||||
'"upstream":"spacetime"',
|
||||
'维护模式允许内网 SpacetimeDB 路由',
|
||||
{ headers: internalClientHeaders },
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/admin/settings',
|
||||
200,
|
||||
'admin-shell',
|
||||
'维护模式允许内网后台页面',
|
||||
{ headers: internalClientHeaders },
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/admin/assets/admin.js',
|
||||
200,
|
||||
'admin asset',
|
||||
'维护模式允许内网后台静态资源',
|
||||
{ headers: internalClientHeaders },
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/admin/api/users',
|
||||
200,
|
||||
'"upstream":"api"',
|
||||
'维护模式允许内网后台 API',
|
||||
{ headers: internalClientHeaders },
|
||||
);
|
||||
await expectHttp(baseUrl, '/', 503, 'maintenance', '维护模式 Web 页面');
|
||||
await expectAccessLogContains(accessLogFile, [
|
||||
'status=503',
|
||||
'path=/api/creation-entry/config',
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs';
|
||||
|
||||
const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json';
|
||||
const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf';
|
||||
const DEVELOPMENT_NGINX_PATH = 'deploy/nginx/genarrative-dev-http.conf';
|
||||
@@ -46,6 +48,11 @@ const REQUIRED_ROUTE_IDS = [
|
||||
'readyz_forbidden',
|
||||
'generated_assets_forbidden',
|
||||
'web_spa_fallback',
|
||||
'web_spa_case_trailing_slash',
|
||||
'web_unknown_path_exact',
|
||||
'creation_unknown_path_exact',
|
||||
'runtime_unknown_path_exact',
|
||||
'puzzle_unknown_path_exact',
|
||||
];
|
||||
|
||||
const files = {
|
||||
@@ -221,6 +228,9 @@ function validateRustTestUsesMatrix() {
|
||||
'serde_json::from_str(ROUTE_PARITY_MATRIX_JSON)',
|
||||
'protection_class_for_route(&route, &case.sample_path)',
|
||||
'fn matches_nginx_route_parity_matrix()',
|
||||
'fn is_main_spa_path(path: &str)',
|
||||
"path.strip_suffix('/')",
|
||||
'normalized.eq_ignore_ascii_case(candidate)',
|
||||
]) {
|
||||
if (!pingoraGatewaySource.includes(fragment)) {
|
||||
fail(`Pingora Rust 路由 parity 测试缺少矩阵接入片段: ${fragment}`);
|
||||
@@ -228,8 +238,34 @@ function validateRustTestUsesMatrix() {
|
||||
}
|
||||
}
|
||||
|
||||
function validateRustMainSpaRoutes() {
|
||||
const routeBlock = pingoraGatewaySource.match(
|
||||
/const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\n\];/u,
|
||||
);
|
||||
if (!routeBlock) {
|
||||
fail('Pingora Rust 缺少 MAIN_SPA_PATHS allowlist。');
|
||||
return;
|
||||
}
|
||||
|
||||
const rustRoutes = Array.from(
|
||||
routeBlock[1].matchAll(/"([^"]+)"/gu),
|
||||
(match) => match[1],
|
||||
).sort();
|
||||
const expected = new Set(expectedMainSpaRoutes);
|
||||
const actual = new Set(rustRoutes);
|
||||
const missing = expectedMainSpaRoutes.filter((route) => !actual.has(route));
|
||||
const extra = rustRoutes.filter((route) => !expected.has(route));
|
||||
if (missing.length > 0) {
|
||||
fail(`Pingora MAIN_SPA_PATHS 缺少当前前端路由: ${missing.join(', ')}`);
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
fail(`Pingora MAIN_SPA_PATHS 包含非当前前端路由: ${extra.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
validateMatrixShape();
|
||||
validateRustTestUsesMatrix();
|
||||
validateRustMainSpaRoutes();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:pingora-route-parity] FAILED');
|
||||
|
||||
@@ -36,9 +36,11 @@ console.log('[check:production-api-deploy] OK');
|
||||
|
||||
function main() {
|
||||
assertDeployCopiesPingoraDirectReleaseDependencies();
|
||||
assertSuccessfulDeployCanKeepMaintenance();
|
||||
assertDeployRestartsActivePingoraWhenArtifactIncluded();
|
||||
assertDeployStartsInactivePingoraWhenArtifactIncluded();
|
||||
assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded();
|
||||
assertDeployStopsAfterPingoraSystemdReadFailure();
|
||||
assertDeployRejectsPingoraPublicListenWhenArtifactIncluded();
|
||||
assertDeployRejectsPingoraArtifactMissingManifestEntry();
|
||||
assertDeployRejectsPingoraManifestEntryMissingArtifact();
|
||||
@@ -78,6 +80,22 @@ function main() {
|
||||
assertMissingPingoraCanaryAccessLogParityFails();
|
||||
}
|
||||
|
||||
function assertSuccessfulDeployCanKeepMaintenance() {
|
||||
const fixture = prepareFixture('keep-maintenance-after-success');
|
||||
const result = runDeploy(fixture, { keepMaintenance: true });
|
||||
|
||||
assertStatus(result, 0, '显式保持维护时完整 fixture 应部署成功。');
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
}
|
||||
assertMaintenanceKept(fixture, '显式要求成功部署后保持维护');
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'按参数保持维护模式',
|
||||
'成功部署并保持维护时必须输出明确状态。',
|
||||
);
|
||||
}
|
||||
|
||||
function readOptionalCommandsLog(fixture) {
|
||||
if (!existsSync(fixture.commandsLog)) {
|
||||
return '';
|
||||
@@ -119,6 +137,18 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() {
|
||||
);
|
||||
}
|
||||
|
||||
assertFileExists(
|
||||
path.join(releaseDir, 'scripts/deploy/production-api-deploy.sh'),
|
||||
'current release 必须包含 API 部署脚本。',
|
||||
);
|
||||
assertFileExists(
|
||||
path.join(releaseDir, 'scripts/deploy/maintenance-on.sh'),
|
||||
'current release 必须包含进入维护脚本。',
|
||||
);
|
||||
assertFileExists(
|
||||
path.join(releaseDir, 'scripts/deploy/maintenance-off.sh'),
|
||||
'current release 必须包含退出维护脚本。',
|
||||
);
|
||||
assertFileExists(
|
||||
path.join(releaseDir, 'scripts/deploy/pingora-direct-enable.sh'),
|
||||
'current release 必须包含 Pingora 直连启用脚本。',
|
||||
@@ -315,8 +345,8 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() {
|
||||
);
|
||||
assertIncludes(
|
||||
commandsLog,
|
||||
'curl -fsS http://127.0.0.1:18082/readyz',
|
||||
'部署脚本必须执行 readiness curl。',
|
||||
'curl -fsS --max-time 2 http://127.0.0.1:18082/readyz',
|
||||
'部署脚本必须为 readiness curl 设置单次超时,避免端口已建立但服务未响应时无限等待。',
|
||||
);
|
||||
|
||||
if (existsSync(fixture.maintenanceFile)) {
|
||||
@@ -416,6 +446,24 @@ function assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded() {
|
||||
'Pingora systemd 已包含 CAP_NET_BIND_SERVICE',
|
||||
'direct-entry capability 存在时必须给出明确错误。',
|
||||
);
|
||||
if (
|
||||
result.stderr.includes(
|
||||
'Pingora systemd 配置缺少包含 GENARRATIVE_PINGORA_GATEWAY_LISTEN 的 EnvironmentFile',
|
||||
)
|
||||
) {
|
||||
failures.push(
|
||||
'direct-entry capability 已被识别后不应继续误报 Pingora EnvironmentFile 缺失。',
|
||||
);
|
||||
}
|
||||
if (
|
||||
result.stderr.includes(
|
||||
'Pingora 自动启动只允许 shadow 监听 127.0.0.1:18081',
|
||||
)
|
||||
) {
|
||||
failures.push(
|
||||
'direct-entry capability 已被识别后不应继续用空 env 误报 shadow listen。',
|
||||
);
|
||||
}
|
||||
const commandsLog = readOptionalCommandsLog(fixture);
|
||||
if (
|
||||
commandsLog.includes(
|
||||
@@ -431,6 +479,47 @@ function assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded() {
|
||||
);
|
||||
}
|
||||
|
||||
function assertDeployStopsAfterPingoraSystemdReadFailure() {
|
||||
const fixture = prepareFixture('with-unreadable-pingora-systemd');
|
||||
addPingoraGatewayArtifact(fixture);
|
||||
const result = runDeploy(fixture, { pingoraSystemctlCatFails: true });
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('包含 Pingora 但无法读取 systemd 最终配置时部署必须失败。');
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
'无法读取 Pingora systemd 最终配置',
|
||||
'systemctl cat 失败时必须保留权威错误。',
|
||||
);
|
||||
if (
|
||||
result.stderr.includes(
|
||||
'Pingora systemd 配置缺少包含 GENARRATIVE_PINGORA_GATEWAY_LISTEN 的 EnvironmentFile',
|
||||
)
|
||||
) {
|
||||
failures.push(
|
||||
'systemctl cat 失败后不应继续误报 Pingora EnvironmentFile 缺失。',
|
||||
);
|
||||
}
|
||||
if (
|
||||
result.stderr.includes(
|
||||
'Pingora 自动启动只允许 shadow 监听 127.0.0.1:18081',
|
||||
)
|
||||
) {
|
||||
failures.push('systemctl cat 失败后不应继续用空 env 误报 shadow listen。');
|
||||
}
|
||||
const commandsLog = readOptionalCommandsLog(fixture);
|
||||
if (
|
||||
commandsLog.includes(
|
||||
'systemctl restart genarrative-pingora-gateway.service',
|
||||
)
|
||||
) {
|
||||
failures.push('systemctl cat 失败后不能自动 restart Pingora。');
|
||||
}
|
||||
assertMaintenanceCleared(fixture, '读取 Pingora systemd 配置失败');
|
||||
assertNoReleasePromoted(fixture, '读取 Pingora systemd 配置失败时');
|
||||
}
|
||||
|
||||
function assertDeployRejectsPingoraPublicListenWhenArtifactIncluded() {
|
||||
const fixture = prepareFixture('with-public-listen-pingora-artifact');
|
||||
addPingoraGatewayArtifact(fixture);
|
||||
@@ -1414,6 +1503,9 @@ function prepareFixture(name) {
|
||||
'worker_state_file="${FAKE_WORKER_STATE_FILE}"',
|
||||
'pingora_state_file="${FAKE_PINGORA_STATE_FILE}"',
|
||||
'if [[ "$1" == "cat" && "${2:-}" == "genarrative-pingora-gateway.service" ]]; then',
|
||||
' if [[ "${FAKE_PINGORA_SYSTEMCTL_CAT_FAIL:-false}" == "true" ]]; then',
|
||||
' exit 1',
|
||||
' fi',
|
||||
' printf "[Service]\\n"',
|
||||
' printf "EnvironmentFile=%s\\n" "${FAKE_PINGORA_ENV_FILE}"',
|
||||
' printf "ExecStart=/opt/genarrative/current/pingora-gateway\\n"',
|
||||
@@ -1603,6 +1695,9 @@ function runDeploy(fixture, options = {}) {
|
||||
if (options.requirePingoraGateway) {
|
||||
args.push('--require-pingora-gateway');
|
||||
}
|
||||
if (options.keepMaintenance) {
|
||||
args.push('--keep-maintenance-mode');
|
||||
}
|
||||
return spawnSync(
|
||||
'bash',
|
||||
args,
|
||||
@@ -1616,6 +1711,8 @@ function runDeploy(fixture, options = {}) {
|
||||
FAKE_PINGORA_ACTIVE: options.pingoraActive === false ? 'false' : 'true',
|
||||
FAKE_PINGORA_DIRECT_ENTRY:
|
||||
options.pingoraDirectEntry === true ? 'true' : 'false',
|
||||
FAKE_PINGORA_SYSTEMCTL_CAT_FAIL:
|
||||
options.pingoraSystemctlCatFails === true ? 'true' : 'false',
|
||||
FAKE_PINGORA_ENV_FILE: fixture.pingoraEnvFile,
|
||||
FAKE_PINGORA_STATE_FILE: fixture.pingoraStateFile,
|
||||
FAKE_CURL_FAIL: options.curlFails === true ? 'true' : 'false',
|
||||
|
||||
@@ -3,6 +3,465 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const checks = [
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/migration.rs',
|
||||
includes:
|
||||
'option_env!("GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256")',
|
||||
reason: '可下载的 SpacetimeDB WASM 只能嵌入 bootstrap secret 摘要。',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/migration.rs',
|
||||
excludes: 'option_env!("GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET")',
|
||||
reason: 'SpacetimeDB WASM 不得嵌入原始 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/migration.rs',
|
||||
includes: 'secret.len() == MIGRATION_BOOTSTRAP_SECRET_HEX_LEN',
|
||||
reason: '模块授权入口必须拒绝非 64 位十六进制 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/build-production-release.sh',
|
||||
includes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256',
|
||||
reason: '生产模块构建前必须把受保护 secret 转换为 SHA-256 摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/build-production-release.sh',
|
||||
excludes: "path: 'migration-bootstrap-secret.txt'",
|
||||
reason: '生产发布清单不得包含原始 bootstrap secret artifact。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/build-production-release.sh',
|
||||
includes: 'migration_bootstrap_secret_sha256:',
|
||||
reason: 'Stdb release manifest 必须记录非敏感 bootstrap secret 摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
includes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256',
|
||||
reason: '远程 Rust 模块构建也只能向 rustc 传递 bootstrap secret 摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
includes: 'server-rs/.spacetimedb/build-secrets',
|
||||
reason: '旧 Ubuntu 直传入口也必须把原始 bootstrap secret 留在发布包外的受保护 sidecar。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
excludes: '"${TARGET_DIR}/migration-bootstrap-secret.txt"',
|
||||
reason: '旧 Ubuntu 发布包目录不得再次包含原始 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
includes: '<"${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"',
|
||||
reason: '远端所需原始 secret 必须通过独立 SSH 标准输入交付,不得进入命令参数或发布包。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
includes: '--migration-bootstrap-secret-file)',
|
||||
reason: '手工直传入口只能从受保护文件读取 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
excludes: '--migration-bootstrap-secret)',
|
||||
reason: 'bootstrap secret 明文不得出现在进程命令参数中。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
includes: '[[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]',
|
||||
reason: '旧 Ubuntu 手工直传入口必须拒绝非 64 位十六进制 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
excludes: "tr -d '\\r\\n'",
|
||||
reason: '旧 Ubuntu 手工直传入口不得把 secret 文件内部换行静默拼接。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'install -o root -g genarrative -m 0440',
|
||||
reason:
|
||||
'生产 Stdb publish 必须把运行时 bootstrap secret 安装成 root 持有、服务组只读的文件。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'restart_runtime_services_after_bootstrap_secret_install',
|
||||
reason:
|
||||
'替换运行时 bootstrap secret 后必须重启已运行的 API、worker 和 controller,避免进程继续持有旧值。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'get_runtime_service_active_state',
|
||||
reason:
|
||||
'生产 Stdb publish 必须区分 systemctl 明确返回的非 active 状态与查询错误,查询错误不得静默跳过服务重启。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '"${exit_code}" -eq 3',
|
||||
reason:
|
||||
'生产 Stdb publish 只能把 systemctl 明确的非 active 返回码作为可跳过状态,其他错误必须阻断。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'if worker_units_output="$(',
|
||||
reason:
|
||||
'生产 Stdb publish 必须直接检查 worker list-units 的退出状态,不能用会吞掉失败的进程替换管道。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '查询 active worker 服务失败,阻断退出维护模式',
|
||||
reason:
|
||||
'生产 Stdb publish 无法枚举 active worker 时必须阻断,不能在未知 worker 状态下退出维护模式。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'restart_runtime_service_and_require_active',
|
||||
reason:
|
||||
'生产 Stdb publish 必须确认重启前 active 的 API、controller 和 worker 在重启后恢复 active。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'if ! restart_runtime_service_and_require_active genarrative-api.service; then',
|
||||
reason:
|
||||
'生产 Stdb publish 必须显式传播 API 重启或验活失败,不能依赖调用上下文中的 set -e。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes:
|
||||
'restart_runtime_services_after_bootstrap_secret_install\nwait_for_api_healthz_ready\n\n"${SCRIPT_DIR}/maintenance-off.sh"',
|
||||
reason:
|
||||
'生产 Stdb publish 在 API 原本 active 时必须先通过本机 /healthz readiness 再退出维护模式。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'ensure_runtime_bootstrap_secret_env_file "${API_ENV_FILE}" true',
|
||||
reason:
|
||||
'Stdb 先于 API 发布时必须先补齐 api-server env 的 FILE 路径,保证首次 rollout 重启即可读取 secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'stop_runtime_services_for_rollout_gate',
|
||||
reason:
|
||||
'Stdb 与 API 之间需要人工维护时,必须停止旧 API/controller/worker,不能只保留网关维护文件。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '按参数保持维护模式和旧运行时服务停止状态',
|
||||
reason: '受控维护发布成功后不得自动重启旧运行时或退出维护模式。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: 'ensure_runtime_bootstrap_secret_file_env',
|
||||
reason:
|
||||
'生产 API/worker env 必须统一指向 Stdb publish 写入的受保护 bootstrap secret 文件。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: '继承已有维护模式;部署失败时不得误退出上游维护窗口',
|
||||
reason: 'API deploy 必须区分自己打开的维护模式与 Stdb gate 继承的维护模式。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: '--keep-maintenance-mode)',
|
||||
reason: 'API deploy 必须允许成功发布后按显式参数保留维护模式。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: 'readiness 通过,按参数保持维护模式',
|
||||
reason: 'API deploy 保留维护模式时必须在 readiness 通过后给出明确状态。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: 'curl -fsS --max-time 2 "${HEALTH_URL}"',
|
||||
reason:
|
||||
'API readiness 单次请求必须有超时,避免端口已建立但服务尚未响应时绕过重试上限无限挂起。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-api-deploy',
|
||||
includes:
|
||||
"booleanParam(name: 'KEEP_MAINTENANCE_MODE', defaultValue: false",
|
||||
reason: 'API Deploy Job 必须向独立发布和 Full 编排暴露成功后保持维护的参数。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-api-deploy',
|
||||
includes: 'maintenance_deploy_args+=(--keep-maintenance-mode)',
|
||||
reason: 'API Deploy Job 必须把保持维护参数传给发布产物内的部署脚本。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/jenkins-server-provision.sh',
|
||||
includes: 'ensure_runtime_bootstrap_secret_file_env',
|
||||
reason:
|
||||
'Server-Provision 必须为新建及存量 API/worker env 补齐 bootstrap secret FILE 配置。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/env/api-server.env.example',
|
||||
includes:
|
||||
'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE=/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt',
|
||||
reason: '生产 API env 示例只能登记 bootstrap secret 的固定 FILE 路径。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: "file(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'MIGRATION_BOOTSTRAP_SECRET_FILE')",
|
||||
reason:
|
||||
'Stdb Publish 必须从 Jenkins Secret File credential 受保护地取得与 wasm 匹配的 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
excludes: 'migration-bootstrap-secret.txt',
|
||||
reason:
|
||||
'Stdb Publish 不得通过普通 Jenkins artifact 或工作区副本交付 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: 'MIGRATION_BOOTSTRAP_SECRET_FILE:?MIGRATION_BOOTSTRAP_SECRET_FILE 不能为空',
|
||||
reason:
|
||||
'Stdb Publish 必须把受保护文件直接传给发布脚本,且不能展开明文。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: "file(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'MIGRATION_BOOTSTRAP_SECRET_FILE')",
|
||||
reason: 'Stdb Build 必须用 Jenkins Secret File credential 为 option_env! 提供受控编译输入。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
excludes: 'archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/migration-bootstrap-secret.txt"',
|
||||
reason: 'bootstrap secret 不得作为普通、可下载的 Jenkins archive artifact。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256',
|
||||
reason: 'Stdb Build 只能把 File credential 的 SHA-256 摘要传给 Rust 编译。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes:
|
||||
"string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file'",
|
||||
reason: 'Stdb Build 的 Secret File credential ID 必须由仓库 Jenkinsfile 固定默认值。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
excludes: 'export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET=',
|
||||
reason: 'Jenkins Stdb Build 不得把原始 bootstrap secret 导出给 Rust 编译进程。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '--migration-bootstrap-secret-file',
|
||||
reason: '生产 Stdb publish 必须只接受受保护的 bootstrap secret FILE。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
excludes: '${SOURCE_DIR}/migration-bootstrap-secret.txt',
|
||||
reason: '生产 Stdb publish 不得从可下载的构建 artifact 读取 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'EXPECTED_MIGRATION_BOOTSTRAP_SECRET_SHA256=',
|
||||
reason: '生产 Stdb publish 必须读取 release manifest 中的 bootstrap secret 摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'Secret File 与构建 WASM 的 bootstrap secret 摘要不一致',
|
||||
reason: '生产 Stdb publish 必须阻断 Secret File 与构建 WASM 摘要不一致。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '--delete-data=never',
|
||||
reason: '生产 Stdb publish 必须显式禁止 schema 冲突时删除数据。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '--yes=migrate,break-clients',
|
||||
reason: '生产 Stdb publish 只能跳过迁移与客户端断开确认,不能使用等价 delete-data 的裸 --yes。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
excludes: '--clear-database',
|
||||
reason: '生产 Stdb publish 普通入口不得保留清库参数。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
excludes: 'CLEAR_DATABASE',
|
||||
reason: 'Stdb 发布流水线不得向普通构建参数暴露清库开关。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '[[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]',
|
||||
reason: '生产 Stdb publish 必须拒绝非 64 位十六进制 Secret File。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: '[[ ! "${migration_bootstrap_secret}" =~ ^[0-9a-fA-F]{64}$ ]]',
|
||||
reason: 'Stdb Build 必须在计算摘要前拒绝非 64 位十六进制 Secret File。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
excludes: 'tr -d "\\\\r\\\\n"',
|
||||
reason: 'Stdb Build 不得把 Secret File 内部换行静默拼接。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-migration-common.mjs',
|
||||
includes: "if (!/^[0-9a-f]{64}$/iu.test(secret))",
|
||||
reason: '迁移脚本 FILE 入口必须拒绝非 64 位十六进制 bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/dev.mjs',
|
||||
includes: 'const BOOTSTRAP_SECRET_PATTERN = /^[0-9a-fA-F]{64}$/u;',
|
||||
reason: '本地 dev 构建与运行服务必须共用严格的 64 位十六进制 secret 规则。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
|
||||
includes: 'rotate_editor_generation_runtime_service_identity_and_return',
|
||||
reason: 'runtime writer identity rotation 必须复用模块的受审计 procedure。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
|
||||
includes: '--confirm-next-writer-identity',
|
||||
reason: 'runtime writer identity rotation 必须要求 operator 二次确认目标 identity。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
|
||||
includes: 'runtime writer identity 不能设为 migration operator identity',
|
||||
reason: 'rotation 入口不能把 migration operator 自动或直接设为 runtime writer。',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/editor_project_storage.rs',
|
||||
includes:
|
||||
'crate::migration::is_database_migration_operator(ctx, next_writer_identity)',
|
||||
reason:
|
||||
'rotation procedure 必须按数据库中的完整 migration operator 集合校验目标 identity。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
|
||||
includes: "spawn('spacetime', ['login', 'show']",
|
||||
reason: 'rotation 入口必须校验声明的 operator identity 与当前 CLI 登录 identity 一致。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
|
||||
excludes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET',
|
||||
reason: 'runtime writer identity rotation 不得读取或传递 migration bootstrap secret。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: '--worker-env-file "${params.WORKER_ENV_FILE}"',
|
||||
reason: 'Stdb Publish 必须把 worker env 路径传给随包发布脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes:
|
||||
"string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file'",
|
||||
reason: 'Stdb Publish 的 Secret File credential ID 必须由仓库 Jenkinsfile 固定默认值。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes: "string(name: 'WORKER_ENV_FILE', value: params.WORKER_ENV_FILE",
|
||||
reason: '全量发布必须向 Stdb 与 API 两段透传同一 worker env 路径。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"error('MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID 必须引用受保护的 Jenkins Secret File 凭据。')",
|
||||
reason: '全量构建必须在启动并行子流水线前拒绝缺失的 Secret File 凭据。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file'",
|
||||
reason: 'Full Build 的 Secret File credential ID 必须由仓库 Jenkinsfile 固定默认值。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', value: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID)",
|
||||
reason: '全量发布必须把与 wasm 构建一致的 Secret File 凭据透传给 Stdb Publish。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['normal', 'pause-after-stdb']",
|
||||
reason: 'Full Build 的 04:00 定时任务必须默认 normal 完整发布仅供开发使用的 dev 服务器。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"choice(name: 'DATABASE_BACKUP_MODE', choices: ['async', 'sync', 'skip']",
|
||||
reason: 'Full Build 必须显式暴露 Stdb publish 的备份策略。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"string(name: 'DATABASE_BACKUP_MODE', value: params.DATABASE_BACKUP_MODE)",
|
||||
reason: 'Full Build 必须把备份策略透传给 Stdb Publish,不得静默回退为 async。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"booleanParam(name: 'KEEP_MAINTENANCE_MODE', value: true)",
|
||||
reason: 'Full Build 必须让 Stdb 与 API Deploy 全程保持维护,直到 Web Deploy 结束。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"booleanParam(name: 'EXIT_MAINTENANCE_MODE_AFTER_COMPLETION', defaultValue: true",
|
||||
reason: 'Full Build 必须显式提供完整发布成功后是否退出维护模式的选项。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
'确认 API runtime identity 未被授权为 migration operator 后再继续部署 API',
|
||||
reason:
|
||||
'Stdb/API 人工 gate 必须提醒审批人保持 migration operator 与运行时服务 identity 互斥。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"stage('Exit Maintenance')",
|
||||
reason: 'Full Build 必须在 Web Deploy 之后用独立阶段决定是否退出维护。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
'expression { return params.EXIT_MAINTENANCE_MODE_AFTER_COMPLETION != false }',
|
||||
reason: 'Full Build 只有在显式允许时才执行最终维护退出阶段。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
'maintenance_script="/opt/genarrative/current/scripts/deploy/maintenance-off.sh"',
|
||||
reason: 'Full Build 必须使用本次 current release 随包维护脚本退出维护。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes: "timeout(time: 4, unit: 'HOURS')",
|
||||
reason: 'Stdb/API 人工门禁必须有超时,不能永久占用 disableConcurrentBuilds 锁。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes: "submitter: params.STDB_API_ROLLOUT_APPROVERS.trim()",
|
||||
reason: 'Stdb/API 人工门禁必须限制指定 Jenkins 用户或组放行。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-maintain-external-generation-jobs.mjs',
|
||||
includes: '!options.apply && (result.has_more || pendingApplyCount > 0)',
|
||||
reason: '历史维护最后一批即使 has_more=false,只要 dry-run 仍命中数据也必须提示同 cursor apply。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-maintain-external-generation-jobs.mjs',
|
||||
includes: 'cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null)',
|
||||
reason: '历史维护续批游标必须编码为 CLI SATS Option,不能把非空字符串直接传给 procedure。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-maintain-external-generation-jobs.mjs',
|
||||
includes: 'completed_before_micros: encodeSpacetimeCliOption(',
|
||||
reason: '历史 payload 截止时间必须编码为 CLI SATS Option,确保事故时间过滤可调用。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
excludes:
|
||||
'echo "[deploy:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"',
|
||||
reason: '发布包构建日志不得输出 bootstrap secret 明文。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/dev.mjs',
|
||||
excludes: '[dev:spacetime] 迁移引导密钥: ${',
|
||||
reason: '本地 dev 日志不得输出 bootstrap secret 明文。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/systemd/genarrative-database-backup.service',
|
||||
includes: '--restart-service-after genarrative-api.service',
|
||||
@@ -6376,6 +6835,30 @@ for (const check of checks) {
|
||||
}
|
||||
}
|
||||
|
||||
const fullPipelineContent = readFileSync(
|
||||
'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
'utf8',
|
||||
);
|
||||
const forcedBuildOnlyCalls = fullPipelineContent.match(
|
||||
/booleanParam\(name: 'PUBLISH_AFTER_BUILD', value: false\)/gu,
|
||||
);
|
||||
if ((forcedBuildOnlyCalls?.length ?? 0) !== 3) {
|
||||
failed = true;
|
||||
console.error(
|
||||
'[check:production-ops] Full Build 必须向 Web、API、Stdb 三个 Build Job 显式传 PUBLISH_AFTER_BUILD=false。',
|
||||
);
|
||||
}
|
||||
|
||||
const fullPipelineMaintenanceHoldCalls = fullPipelineContent.match(
|
||||
/booleanParam\(name: 'KEEP_MAINTENANCE_MODE', value: true\)/gu,
|
||||
);
|
||||
if ((fullPipelineMaintenanceHoldCalls?.length ?? 0) !== 2) {
|
||||
failed = true;
|
||||
console.error(
|
||||
'[check:production-ops] Full Build 必须让 Stdb Publish 与 API Deploy 两个下游阶段都保持维护模式。',
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of nodeEnvFileCommandFiles) {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
const commandText = content.replace(/\\\r?\n\s*/g, ' ');
|
||||
|
||||
@@ -37,11 +37,11 @@ chmod +x "${TARGET_BIN_DIR}/otelcol-contrib"
|
||||
|
||||
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "spacetimedb-cli 2.5.0"
|
||||
echo "spacetimedb-cli 2.6.0"
|
||||
EOF
|
||||
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "spacetimedb-standalone 2.5.0"
|
||||
echo "spacetimedb-standalone 2.6.0"
|
||||
EOF
|
||||
chmod +x \
|
||||
"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" \
|
||||
@@ -58,7 +58,7 @@ if ! (
|
||||
OTELCOL_TARGET_BIN="${TARGET_BIN_DIR}/otelcol-contrib" \
|
||||
OTELCOL_VERSION="0.151.0" \
|
||||
SPACETIME_ROOT="${SPACETIME_ROOT_DIR}" \
|
||||
SPACETIME_EXPECTED_VERSION="2.5.0" \
|
||||
SPACETIME_EXPECTED_VERSION="2.6.0" \
|
||||
"${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \
|
||||
>"${OUTPUT_LOG}" 2>&1
|
||||
); then
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildReconcileFingerprint,
|
||||
calcPaySig,
|
||||
normalizeLocalOrderSnapshot,
|
||||
notifyWechatGoodsDelivered,
|
||||
paidAtMicrosFromWechatOrder,
|
||||
parseEnvText,
|
||||
validateQueryResult,
|
||||
} from './reconcile-wechat-virtual-payment-order.mjs';
|
||||
|
||||
assert.equal(
|
||||
calcPaySig(
|
||||
'12345',
|
||||
'/xpay/query_user_balance',
|
||||
'{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}',
|
||||
),
|
||||
'c37809f27c6d7fd1837ad2500a04512b66b34fd793a39a385fade56dca89a4b5',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
parseEnvText(
|
||||
'WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 # 现网\nWECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY="app-key"\n',
|
||||
),
|
||||
{
|
||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY: 'app-key',
|
||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV: '0',
|
||||
},
|
||||
);
|
||||
|
||||
const localOrder = { amountCents: 600, orderId: 'order-001' };
|
||||
const paidOrder = {
|
||||
order_fee: 600,
|
||||
order_id: 'order-001',
|
||||
order_type: 0,
|
||||
status: 2,
|
||||
};
|
||||
assert.deepEqual(validateQueryResult(localOrder, paidOrder), {
|
||||
eligibleForCredit: true,
|
||||
status: 2,
|
||||
});
|
||||
assert.deepEqual(validateQueryResult(localOrder, { ...paidOrder, status: 1 }), {
|
||||
eligibleForCredit: false,
|
||||
status: 1,
|
||||
});
|
||||
assert.deepEqual(
|
||||
validateQueryResult(localOrder, { ...paidOrder, order_type: 7 }),
|
||||
{ eligibleForCredit: true, status: 2 },
|
||||
);
|
||||
assert.equal(
|
||||
paidAtMicrosFromWechatOrder({ paid_time: 1_777_111_200 }),
|
||||
1_777_111_200_000_000,
|
||||
);
|
||||
assert.throws(() => paidAtMicrosFromWechatOrder({}), /paid_time/u);
|
||||
assert.throws(
|
||||
() => paidAtMicrosFromWechatOrder({ paid_time: Number.MAX_SAFE_INTEGER }),
|
||||
/安全整数范围/u,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateQueryResult(localOrder, { ...paidOrder, order_fee: 601 }),
|
||||
/金额/u,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateQueryResult(localOrder, { ...paidOrder, order_type: 1 }),
|
||||
/可入账虚拟支付单/u,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateQueryResult(localOrder, { ...paidOrder, order_type: 8 }),
|
||||
/可入账虚拟支付单/u,
|
||||
);
|
||||
assert.equal(
|
||||
buildReconcileFingerprint({ orderId: 'order-001', status: 2 }),
|
||||
buildReconcileFingerprint({ orderId: 'order-001', status: 2 }),
|
||||
);
|
||||
assert.notEqual(
|
||||
buildReconcileFingerprint({ orderId: 'order-001', status: 2 }),
|
||||
buildReconcileFingerprint({ orderId: 'order-001', status: 1 }),
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLocalOrderSnapshot([
|
||||
'order-001',
|
||||
'user-001',
|
||||
'points_60',
|
||||
'60 泥点',
|
||||
[0],
|
||||
600,
|
||||
[0],
|
||||
'wechat_mp_virtual',
|
||||
[1],
|
||||
[1],
|
||||
1_777_111_200_000_000,
|
||||
0,
|
||||
[1],
|
||||
[1],
|
||||
[1],
|
||||
[1],
|
||||
[1],
|
||||
]),
|
||||
{
|
||||
amount_cents: 600,
|
||||
kind: [0],
|
||||
order_id: 'order-001',
|
||||
payment_channel: 'wechat_mp_virtual',
|
||||
status: [0],
|
||||
user_id: 'user-001',
|
||||
},
|
||||
);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let notifyRequest;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
notifyRequest = { url: String(url), init };
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
};
|
||||
};
|
||||
try {
|
||||
await notifyWechatGoodsDelivered(
|
||||
{
|
||||
accessToken: 'access-token-001',
|
||||
notifyProvideGoodsEndpoint:
|
||||
'https://api.weixin.qq.com/xpay/notify_provide_goods',
|
||||
paymentEnv: 0,
|
||||
},
|
||||
'order-001',
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.equal(
|
||||
notifyRequest.url,
|
||||
'https://api.weixin.qq.com/xpay/notify_provide_goods?access_token=access-token-001',
|
||||
);
|
||||
assert.equal(notifyRequest.init.method, 'POST');
|
||||
assert.equal(notifyRequest.init.body, '{"order_id":"order-001","env":0}');
|
||||
|
||||
console.log('wechat virtual payment reconcile checks passed');
|
||||
@@ -761,7 +761,7 @@ function composeEnv() {
|
||||
}
|
||||
|
||||
function localSpacetimeImageName() {
|
||||
return `${projectName}-spacetimedb:2.5.0`;
|
||||
return `${projectName}-spacetimedb:2.6.0`;
|
||||
}
|
||||
|
||||
function spacetimeServerUrl(state) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createHash, createHmac} from 'node:crypto';
|
||||
import {createReadStream, existsSync, mkdirSync, readFileSync, rmSync, statSync, statfsSync, writeFileSync} from 'node:fs';
|
||||
import {createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, statfsSync, writeFileSync} from 'node:fs';
|
||||
import {basename, dirname, isAbsolute, resolve} from 'node:path';
|
||||
import {setTimeout as sleep} from 'node:timers/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -18,6 +19,14 @@ const OSS_ALGORITHM = 'OSS4-HMAC-SHA256';
|
||||
const OSS_SERVICE = 'oss';
|
||||
const OSS_REQUEST = 'aliyun_v4_request';
|
||||
const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
|
||||
const DEFAULT_OSS_MULTIPART_PART_SIZE_BYTES = 128 * 1024 * 1024;
|
||||
const OSS_MIN_MULTIPART_PART_SIZE_BYTES = 100 * 1024;
|
||||
const OSS_MAX_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024 * 1024;
|
||||
const OSS_MAX_MULTIPART_PARTS = 10_000;
|
||||
const DEFAULT_OSS_REQUEST_MAX_ATTEMPTS = 5;
|
||||
const DEFAULT_OSS_RETRY_BASE_DELAY_MS = 1_000;
|
||||
const DEFAULT_OSS_RETRY_MAX_DELAY_MS = 30_000;
|
||||
const RETRYABLE_OSS_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
||||
|
||||
function usage() {
|
||||
console.log(`用法:
|
||||
@@ -484,11 +493,33 @@ function encodePath(path) {
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function encodeQueryComponent(value) {
|
||||
return encodeURIComponent(String(value)).replace(
|
||||
/[!'()*]/gu,
|
||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCanonicalQuery(queries = {}) {
|
||||
return Object.entries(queries)
|
||||
.map(([key, value]) => [encodeQueryComponent(key), value === null ? null : encodeQueryComponent(value)])
|
||||
.sort(([leftKey, leftValue], [rightKey, rightValue]) => {
|
||||
if (leftKey !== rightKey) {
|
||||
return leftKey < rightKey ? -1 : 1;
|
||||
}
|
||||
const left = leftValue ?? '';
|
||||
const right = rightValue ?? '';
|
||||
return left === right ? 0 : left < right ? -1 : 1;
|
||||
})
|
||||
.map(([key, value]) => value === null ? key : `${key}=${value}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function canonicalHeaderValue(value) {
|
||||
return String(value).trim().replace(/\s+/gu, ' ');
|
||||
}
|
||||
|
||||
function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, headers, date}) {
|
||||
export function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, headers, date, queries = {}}) {
|
||||
const region = regionFromEndpoint(endpoint);
|
||||
const scopeDate = formatScopeDate(date);
|
||||
const scope = `${scopeDate}/${region}/${OSS_SERVICE}/${OSS_REQUEST}`;
|
||||
@@ -504,7 +535,7 @@ function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, a
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
canonicalUri,
|
||||
'',
|
||||
buildCanonicalQuery(queries),
|
||||
canonicalHeaders,
|
||||
additionalHeaders,
|
||||
UNSIGNED_PAYLOAD,
|
||||
@@ -518,56 +549,304 @@ function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, a
|
||||
return `${OSS_ALGORITHM} Credential=${accessKeyId}/${scope},AdditionalHeaders=${additionalHeaders},Signature=${finalSignature}`;
|
||||
}
|
||||
|
||||
async function uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret}) {
|
||||
function buildOssUrl({bucket, endpoint, objectKey, queries = {}}) {
|
||||
const canonicalQuery = buildCanonicalQuery(queries);
|
||||
return `https://${bucket}.${endpoint}/${encodePath(objectKey)}${canonicalQuery ? `?${canonicalQuery}` : ''}`;
|
||||
}
|
||||
|
||||
function isRetryableOssStatus(status) {
|
||||
return RETRYABLE_OSS_HTTP_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function retryDelayMs({attempt, baseDelayMs, maxDelayMs, randomFn}) {
|
||||
const ceiling = Math.min(maxDelayMs, baseDelayMs * (2 ** Math.max(0, attempt - 1)));
|
||||
return Math.floor(randomFn() * ceiling);
|
||||
}
|
||||
|
||||
async function signedOssRequest({
|
||||
method,
|
||||
bucket,
|
||||
endpoint,
|
||||
objectKey,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
queries = {},
|
||||
headers = {},
|
||||
bodyFactory,
|
||||
contentLength,
|
||||
operation,
|
||||
fetchImpl,
|
||||
nowFn,
|
||||
sleepImpl,
|
||||
randomFn,
|
||||
maxAttempts,
|
||||
retryBaseDelayMs,
|
||||
retryMaxDelayMs,
|
||||
}) {
|
||||
const targetUrl = buildOssUrl({bucket, endpoint, objectKey, queries});
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const now = nowFn();
|
||||
const signedHeaders = {
|
||||
host: `${bucket}.${endpoint}`,
|
||||
...headers,
|
||||
'x-oss-content-sha256': UNSIGNED_PAYLOAD,
|
||||
'x-oss-date': formatOssDate(now),
|
||||
};
|
||||
const authorization = buildAuthorization({
|
||||
method,
|
||||
bucket,
|
||||
endpoint,
|
||||
objectKey,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
headers: signedHeaders,
|
||||
date: now,
|
||||
queries,
|
||||
});
|
||||
const requestHeaders = {...signedHeaders, authorization};
|
||||
if (contentLength !== undefined) {
|
||||
requestHeaders['content-length'] = String(contentLength);
|
||||
}
|
||||
const body = bodyFactory ? bodyFactory() : undefined;
|
||||
const requestOptions = {method, headers: requestHeaders};
|
||||
if (body !== undefined) {
|
||||
requestOptions.body = body;
|
||||
requestOptions.duplex = 'half';
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(targetUrl, requestOptions);
|
||||
} catch (error) {
|
||||
lastError = new Error(`OSS ${operation}请求失败: oss://${bucket}/${objectKey}`, {cause: error});
|
||||
}
|
||||
|
||||
if (response?.ok) {
|
||||
return response;
|
||||
}
|
||||
if (response) {
|
||||
const responseText = await response.text();
|
||||
const requestId = response.headers.get('x-oss-request-id');
|
||||
lastError = new Error(
|
||||
`OSS ${operation}失败 HTTP ${response.status}${requestId ? ` requestId=${requestId}` : ''}: ${responseText.slice(0, 500)}`,
|
||||
);
|
||||
lastError.status = response.status;
|
||||
}
|
||||
|
||||
const retryable = !response || isRetryableOssStatus(response.status);
|
||||
if (!retryable || attempt >= maxAttempts) {
|
||||
throw lastError;
|
||||
}
|
||||
const delayMs = retryDelayMs({attempt, baseDelayMs: retryBaseDelayMs, maxDelayMs: retryMaxDelayMs, randomFn});
|
||||
console.warn(`[database-backup] OSS ${operation}失败,${delayMs}ms 后重试 (${attempt}/${maxAttempts})`);
|
||||
await sleepImpl(delayMs);
|
||||
}
|
||||
|
||||
throw lastError ?? new Error(`OSS ${operation}失败: oss://${bucket}/${objectKey}`);
|
||||
}
|
||||
|
||||
function readXmlTag(xml, tagName) {
|
||||
const match = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'u').exec(xml);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
return match[1]
|
||||
.replace(/</gu, '<')
|
||||
.replace(/>/gu, '>')
|
||||
.replace(/"/gu, '"')
|
||||
.replace(/'/gu, "'")
|
||||
.replace(/&/gu, '&')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/gu, '&')
|
||||
.replace(/</gu, '<')
|
||||
.replace(/>/gu, '>')
|
||||
.replace(/"/gu, '"')
|
||||
.replace(/'/gu, ''');
|
||||
}
|
||||
|
||||
function buildCompleteMultipartBody(parts) {
|
||||
const partXml = parts
|
||||
.map(({partNumber, etag}) => [
|
||||
'<Part>',
|
||||
`<PartNumber>${partNumber}</PartNumber>`,
|
||||
`<ETag>${escapeXml(etag)}</ETag>`,
|
||||
'</Part>',
|
||||
].join(''))
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>${partXml}</CompleteMultipartUpload>`;
|
||||
}
|
||||
|
||||
function resolveMultipartPartSize(fileSize, configuredPartSize) {
|
||||
if (!Number.isSafeInteger(configuredPartSize) || configuredPartSize < OSS_MIN_MULTIPART_PART_SIZE_BYTES) {
|
||||
throw new Error(`OSS multipart part size 必须是 >= ${OSS_MIN_MULTIPART_PART_SIZE_BYTES} 的安全整数`);
|
||||
}
|
||||
const minimumForPartLimit = Math.ceil(fileSize / OSS_MAX_MULTIPART_PARTS);
|
||||
const partSize = Math.max(configuredPartSize, minimumForPartLimit);
|
||||
if (partSize > OSS_MAX_MULTIPART_PART_SIZE_BYTES) {
|
||||
throw new Error(`OSS multipart part size 超过 5GiB: ${partSize}`);
|
||||
}
|
||||
return partSize;
|
||||
}
|
||||
|
||||
async function verifyUploadedObject({requestOptions, expectedContentLength}) {
|
||||
const response = await signedOssRequest({
|
||||
...requestOptions,
|
||||
method: 'HEAD',
|
||||
operation: 'HEAD 验证',
|
||||
});
|
||||
const contentLengthHeader = response.headers.get('content-length');
|
||||
if (!contentLengthHeader || !/^\d+$/u.test(contentLengthHeader)) {
|
||||
throw new Error(`OSS HEAD 验证缺少有效 content-length: ${contentLengthHeader ?? '<missing>'}`);
|
||||
}
|
||||
const remoteContentLength = Number(contentLengthHeader);
|
||||
if (remoteContentLength !== expectedContentLength) {
|
||||
throw new Error(`OSS HEAD 验证长度不一致: local=${expectedContentLength}, remote=${remoteContentLength}`);
|
||||
}
|
||||
return {verifiedAt: new Date().toISOString(), remoteContentLength};
|
||||
}
|
||||
|
||||
async function abortMultipartUpload({requestOptions, uploadId}) {
|
||||
try {
|
||||
await signedOssRequest({
|
||||
...requestOptions,
|
||||
method: 'DELETE',
|
||||
queries: {uploadId},
|
||||
operation: 'AbortMultipartUpload',
|
||||
maxAttempts: Math.min(2, requestOptions.maxAttempts),
|
||||
});
|
||||
console.warn(`[database-backup] 已清理失败的 multipart upload: ${uploadId}`);
|
||||
} catch (error) {
|
||||
console.warn(`[database-backup] 清理 multipart upload 失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadArchive({
|
||||
archivePath,
|
||||
bucket,
|
||||
endpoint,
|
||||
objectKey,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
partSizeBytes = DEFAULT_OSS_MULTIPART_PART_SIZE_BYTES,
|
||||
maxAttempts = DEFAULT_OSS_REQUEST_MAX_ATTEMPTS,
|
||||
retryBaseDelayMs = DEFAULT_OSS_RETRY_BASE_DELAY_MS,
|
||||
retryMaxDelayMs = DEFAULT_OSS_RETRY_MAX_DELAY_MS,
|
||||
fetchImpl = globalThis.fetch,
|
||||
nowFn = () => new Date(),
|
||||
sleepImpl = sleep,
|
||||
randomFn = Math.random,
|
||||
}) {
|
||||
const fileStat = statSync(archivePath);
|
||||
const now = new Date();
|
||||
const targetUrl = `https://${bucket}.${endpoint}/${encodePath(objectKey)}`;
|
||||
const headers = {
|
||||
host: `${bucket}.${endpoint}`,
|
||||
'content-type': 'application/gzip',
|
||||
'x-oss-content-sha256': UNSIGNED_PAYLOAD,
|
||||
'x-oss-date': formatOssDate(now),
|
||||
'x-oss-meta-backup-kind': 'spacetimedb-data-dir',
|
||||
};
|
||||
const authorization = buildAuthorization({
|
||||
method: 'PUT',
|
||||
if (!fileStat.isFile() || fileStat.size <= 0) {
|
||||
throw new Error(`待上传备份必须是非空文件: ${archivePath}`);
|
||||
}
|
||||
const partSize = resolveMultipartPartSize(fileStat.size, partSizeBytes);
|
||||
const partCount = Math.ceil(fileStat.size / partSize);
|
||||
const requestOptions = {
|
||||
bucket,
|
||||
endpoint,
|
||||
objectKey,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
headers,
|
||||
date: now,
|
||||
});
|
||||
|
||||
console.log(`[database-backup] 上传 OSS: oss://${bucket}/${objectKey}`);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...headers,
|
||||
authorization,
|
||||
'content-length': String(fileStat.size),
|
||||
},
|
||||
body: createReadStream(archivePath),
|
||||
duplex: 'half',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`OSS 上传请求失败: oss://${bucket}/${objectKey}`, {cause: error});
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`OSS 上传失败 HTTP ${response.status}: ${responseText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
bucket,
|
||||
objectKey,
|
||||
contentLength: fileStat.size,
|
||||
etag: response.headers.get('etag')?.replace(/^"|"$/gu, '') ?? '',
|
||||
fetchImpl,
|
||||
nowFn,
|
||||
sleepImpl,
|
||||
randomFn,
|
||||
maxAttempts,
|
||||
retryBaseDelayMs,
|
||||
retryMaxDelayMs,
|
||||
};
|
||||
let uploadId = '';
|
||||
let uploadCompleted = false;
|
||||
|
||||
console.log(`[database-backup] multipart 上传 OSS: oss://${bucket}/${objectKey} (${partCount} parts)`);
|
||||
try {
|
||||
const initiateResponse = await signedOssRequest({
|
||||
...requestOptions,
|
||||
method: 'POST',
|
||||
queries: {uploads: null},
|
||||
headers: {
|
||||
'content-type': 'application/gzip',
|
||||
'x-oss-meta-backup-kind': 'spacetimedb-data-dir',
|
||||
},
|
||||
operation: 'InitiateMultipartUpload',
|
||||
});
|
||||
uploadId = readXmlTag(await initiateResponse.text(), 'UploadId');
|
||||
if (!uploadId) {
|
||||
throw new Error('OSS InitiateMultipartUpload 响应缺少 UploadId');
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
for (let partNumber = 1; partNumber <= partCount; partNumber += 1) {
|
||||
const start = (partNumber - 1) * partSize;
|
||||
const end = Math.min(fileStat.size, start + partSize) - 1;
|
||||
const contentLength = end - start + 1;
|
||||
const response = await signedOssRequest({
|
||||
...requestOptions,
|
||||
method: 'PUT',
|
||||
queries: {partNumber, uploadId},
|
||||
headers: {'content-type': 'application/octet-stream'},
|
||||
contentLength,
|
||||
bodyFactory: () => createReadStream(archivePath, {start, end}),
|
||||
operation: `UploadPart ${partNumber}/${partCount}`,
|
||||
});
|
||||
const etag = response.headers.get('etag');
|
||||
if (!etag) {
|
||||
throw new Error(`OSS UploadPart ${partNumber}/${partCount} 响应缺少 ETag`);
|
||||
}
|
||||
parts.push({partNumber, etag});
|
||||
console.log(`[database-backup] multipart 进度: ${partNumber}/${partCount}`);
|
||||
}
|
||||
|
||||
const completeBody = buildCompleteMultipartBody(parts);
|
||||
let completeResponse;
|
||||
try {
|
||||
completeResponse = await signedOssRequest({
|
||||
...requestOptions,
|
||||
method: 'POST',
|
||||
queries: {uploadId},
|
||||
headers: {'content-type': 'application/xml'},
|
||||
contentLength: Buffer.byteLength(completeBody),
|
||||
bodyFactory: () => completeBody,
|
||||
operation: 'CompleteMultipartUpload',
|
||||
});
|
||||
const completeResponseText = await completeResponse.text();
|
||||
if (/<Error(?:\s|>)/u.test(completeResponseText)) {
|
||||
throw new Error(`OSS CompleteMultipartUpload 返回错误: ${completeResponseText.slice(0, 500)}`);
|
||||
}
|
||||
} catch (completeError) {
|
||||
try {
|
||||
await verifyUploadedObject({requestOptions, expectedContentLength: fileStat.size});
|
||||
completeResponse = null;
|
||||
} catch {
|
||||
throw completeError;
|
||||
}
|
||||
}
|
||||
|
||||
const verification = await verifyUploadedObject({requestOptions, expectedContentLength: fileStat.size});
|
||||
uploadCompleted = true;
|
||||
return {
|
||||
bucket,
|
||||
objectKey,
|
||||
contentLength: fileStat.size,
|
||||
etag: completeResponse?.headers.get('etag')?.replace(/^"|"$/gu, '') ?? '',
|
||||
uploadMode: 'multipart',
|
||||
partCount,
|
||||
partSizeBytes: partSize,
|
||||
verifiedAt: verification.verifiedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if (uploadId && !uploadCompleted) {
|
||||
await abortMultipartUpload({requestOptions, uploadId});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix}) {
|
||||
@@ -603,6 +882,10 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId,
|
||||
objectKey: result.objectKey,
|
||||
contentLength: result.contentLength,
|
||||
etag: result.etag,
|
||||
uploadMode: result.uploadMode,
|
||||
partCount: result.partCount,
|
||||
partSizeBytes: result.partSizeBytes,
|
||||
verifiedAt: result.verifiedAt,
|
||||
uploadedAt,
|
||||
uploadStatus: 'uploaded',
|
||||
},
|
||||
@@ -736,6 +1019,10 @@ async function main() {
|
||||
archivePath,
|
||||
contentLength: result.contentLength,
|
||||
etag: result.etag,
|
||||
uploadMode: result.uploadMode,
|
||||
partCount: result.partCount,
|
||||
partSizeBytes: result.partSizeBytes,
|
||||
verifiedAt: result.verifiedAt,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadStatus: 'uploaded',
|
||||
},
|
||||
@@ -791,9 +1078,11 @@ function describeError(error) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
for (const line of describeError(error)) {
|
||||
console.error(`[database-backup] ${line}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(__filename)) {
|
||||
main().catch((error) => {
|
||||
for (const line of describeError(error)) {
|
||||
console.error(`[database-backup] ${line}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ usage() {
|
||||
--skip-web-build 跳过 Vite 构建,仅用于调试
|
||||
--skip-api-build 跳过 api-server 构建,仅用于调试
|
||||
--skip-spacetime-build 跳过 wasm 构建,仅用于调试;此时必须同时传 --no-migration-bootstrap-secret
|
||||
--migration-bootstrap-secret-file <path>
|
||||
从受保护文件读取迁移引导密钥,明文不得放进命令参数
|
||||
--no-migration-bootstrap-secret 构建不带迁移引导密钥的 spacetime-module wasm
|
||||
|
||||
目标服务器要求:
|
||||
@@ -163,13 +165,20 @@ prepare_migration_bootstrap_secret() {
|
||||
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
|
||||
;;
|
||||
manual)
|
||||
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
|
||||
echo "[deploy:rust] 迁移引导密钥至少需要 16 个字符。" >&2
|
||||
if [[ ! -f "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" || -L "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" || ! -r "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" ]]; then
|
||||
echo "[deploy:rust] 迁移引导密钥必须来自可读普通文件,且不能是符号链接: ${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
MIGRATION_BOOTSTRAP_SECRET="$(cat "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}")"
|
||||
MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET%$'\r'}"
|
||||
if [[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
||||
echo "[deploy:rust] 迁移引导密钥必须是 64 位十六进制高熵值。" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
disabled)
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256
|
||||
echo "[deploy:rust] 未启用迁移引导密钥。"
|
||||
return
|
||||
;;
|
||||
@@ -179,8 +188,11 @@ prepare_migration_bootstrap_secret() {
|
||||
;;
|
||||
esac
|
||||
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
echo "[deploy:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"
|
||||
local digest_line
|
||||
digest_line="$(printf '%s' "${MIGRATION_BOOTSTRAP_SECRET}" | sha256sum)"
|
||||
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="${digest_line%% *}"
|
||||
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
|
||||
echo "[deploy:rust] 已启用迁移引导密钥: mode=${MIGRATION_BOOTSTRAP_SECRET_MODE}, length=${#MIGRATION_BOOTSTRAP_SECRET}"
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -204,6 +216,7 @@ SKIP_API_BUILD=0
|
||||
SKIP_SPACETIME_BUILD=0
|
||||
BUILD_COMPLETED=0
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -272,8 +285,8 @@ while [[ $# -gt 0 ]]; do
|
||||
SKIP_SPACETIME_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--migration-bootstrap-secret)
|
||||
MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}"
|
||||
--migration-bootstrap-secret-file)
|
||||
MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE="$(normalize_local_path_for_bash "${2:?缺少 --migration-bootstrap-secret-file 的值}")"
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="manual"
|
||||
shift 2
|
||||
;;
|
||||
@@ -313,12 +326,16 @@ 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"
|
||||
MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE=""
|
||||
|
||||
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
|
||||
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE:-}" ]]; then
|
||||
rm -f "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup_partial_build EXIT
|
||||
@@ -333,6 +350,24 @@ require_command cargo
|
||||
|
||||
prepare_migration_bootstrap_secret
|
||||
|
||||
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
|
||||
migration_bootstrap_secret_dir="${SERVER_RS_DIR}/.spacetimedb/build-secrets"
|
||||
MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE="${migration_bootstrap_secret_dir}/${BUILD_NAME}.txt"
|
||||
if [[ -e "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}" ]]; then
|
||||
echo "[deploy:rust] 迁移引导密钥文件已存在: ${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${migration_bootstrap_secret_dir}"
|
||||
chmod 700 "${migration_bootstrap_secret_dir}"
|
||||
(
|
||||
umask 077
|
||||
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
|
||||
)
|
||||
chmod 600 "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
|
||||
echo "[deploy:rust] 迁移引导密钥已单独写入受保护文件: ${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
|
||||
unset MIGRATION_BOOTSTRAP_SECRET
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||
require_command npm
|
||||
fi
|
||||
@@ -402,11 +437,6 @@ 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 \
|
||||
@@ -735,37 +765,38 @@ is_publish_conflict_output() {
|
||||
|| [[ "${normalized}" == *"clear database"* && "${normalized}" == *"publish"* ]]
|
||||
}
|
||||
|
||||
read_migration_bootstrap_secret() {
|
||||
resolve_migration_bootstrap_secret_file() {
|
||||
local secret_file="$1"
|
||||
local label="$2"
|
||||
local secret=""
|
||||
|
||||
if [[ ! -f "${secret_file}" ]]; then
|
||||
if [[ ! -f "${secret_file}" || -L "${secret_file}" || ! -r "${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
|
||||
secret="$(cat "${secret_file}")"
|
||||
secret="${secret%$'\r'}"
|
||||
if [[ ! "${secret}" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
||||
echo "[start] 迁移引导密钥必须是 64 位十六进制高熵值${label}: ${secret_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf "%s" "${secret}"
|
||||
printf "%s" "${secret_file}"
|
||||
}
|
||||
|
||||
read_export_migration_bootstrap_secret() {
|
||||
resolve_export_migration_bootstrap_secret_file() {
|
||||
if [[ -f "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||||
read_migration_bootstrap_secret "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" "(旧模块导出)"
|
||||
resolve_migration_bootstrap_secret_file "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" "(旧模块导出)"
|
||||
return
|
||||
fi
|
||||
|
||||
read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(当前模块导出兜底)"
|
||||
resolve_migration_bootstrap_secret_file "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(当前模块导出兜底)"
|
||||
}
|
||||
|
||||
read_import_migration_bootstrap_secret() {
|
||||
read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(新模块导入)"
|
||||
resolve_import_migration_bootstrap_secret_file() {
|
||||
resolve_migration_bootstrap_secret_file "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(新模块导入)"
|
||||
}
|
||||
|
||||
require_migration_script() {
|
||||
@@ -790,8 +821,8 @@ run_publish() {
|
||||
}
|
||||
|
||||
run_conflict_migration_publish() {
|
||||
local export_bootstrap_secret=""
|
||||
local import_bootstrap_secret=""
|
||||
local export_bootstrap_secret_file=""
|
||||
local import_bootstrap_secret_file=""
|
||||
local export_auth_args=()
|
||||
local import_auth_args=()
|
||||
local migration_database_slug=""
|
||||
@@ -803,16 +834,16 @@ run_conflict_migration_publish() {
|
||||
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}")
|
||||
export_bootstrap_secret_file="$(resolve_export_migration_bootstrap_secret_file)"
|
||||
export_auth_args=(--bootstrap-secret-file "${export_bootstrap_secret_file}")
|
||||
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}")
|
||||
import_bootstrap_secret_file="$(resolve_import_migration_bootstrap_secret_file)"
|
||||
import_auth_args=(--bootstrap-secret-file "${import_bootstrap_secret_file}")
|
||||
fi
|
||||
require_migration_script "${MIGRATION_EXPORT_SCRIPT}"
|
||||
require_migration_script "${MIGRATION_IMPORT_SCRIPT}"
|
||||
@@ -1107,7 +1138,7 @@ fi
|
||||
|
||||
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
|
||||
if [[ -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||||
echo "[start] 迁移引导密钥: $(cat "${MIGRATION_BOOTSTRAP_SECRET_FILE}")"
|
||||
echo "[start] 已启用迁移引导密钥。"
|
||||
else
|
||||
echo "[start] 未启用迁移引导密钥。"
|
||||
fi
|
||||
@@ -1140,7 +1171,14 @@ 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}"
|
||||
if [[ -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
|
||||
export GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE="${MIGRATION_BOOTSTRAP_SECRET_FILE}"
|
||||
else
|
||||
unset GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE
|
||||
fi
|
||||
|
||||
start_process api-server "${SCRIPT_DIR}/api-server"
|
||||
unset GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE
|
||||
|
||||
export GENARRATIVE_WEB_HOST="${WEB_HOST}"
|
||||
export GENARRATIVE_WEB_PORT="${WEB_PORT}"
|
||||
@@ -1216,7 +1254,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
|
||||
- \`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\` 发布时会显示,迁移授权完成后可删除
|
||||
- \`migration-bootstrap-secret.txt\`:不包含在发布包中;上传模式会通过独立 SSH 输入流安装为 \`0600\`,\`--skip-upload\` 时必须从构建端受保护的 \`server-rs/.spacetimedb/build-secrets/<build-name>.txt\` 单独交付到发布目录
|
||||
- \`scripts/spacetime-*.mjs\`:部署时 schema 冲突自动导出、导入回灌使用的 SpacetimeDB 迁移脚本
|
||||
- \`web-server.mjs\`:静态网站与 API 反代入口
|
||||
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
|
||||
@@ -1255,7 +1293,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
|
||||
- \`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\` 发布日志都会显示同一份密钥。
|
||||
- 迁移引导密钥由构建端随机生成,原文不进入发布包、构建日志或生成 README;服务器 \`start.sh\` 只读取同目录受保护文件且不会输出明文。
|
||||
EOF
|
||||
replace_placeholder_in_file "${TARGET_DIR}/README.md" "__GENARRATIVE_BUILD_NAME__" "${BUILD_NAME}"
|
||||
|
||||
@@ -1267,6 +1305,17 @@ if [[ "${UPLOAD_ENABLED}" -eq 1 ]]; then
|
||||
|
||||
echo "[deploy:rust] 上传发布包: ${TARGET_DIR} -> ${REMOTE_TARGET}:${REMOTE_DIR}/"
|
||||
scp -r -i "${NORMALIZED_SSH_KEY}" "${TARGET_DIR}" "${REMOTE_TARGET}:${REMOTE_DIR}/"
|
||||
|
||||
if [[ -n "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}" ]]; then
|
||||
remote_secret_path="${REMOTE_DIR%/}/${BUILD_NAME}/migration-bootstrap-secret.txt"
|
||||
remote_secret_temp_path="${REMOTE_DIR%/}/${BUILD_NAME}/.migration-bootstrap-secret.$$.tmp"
|
||||
quoted_remote_secret_path="$(remote_shell_quote "${remote_secret_path}")"
|
||||
quoted_remote_secret_temp_path="$(remote_shell_quote "${remote_secret_temp_path}")"
|
||||
echo "[deploy:rust] 通过独立 SSH 输入流安装迁移引导密钥: ${REMOTE_TARGET}:${remote_secret_path}"
|
||||
ssh -i "${NORMALIZED_SSH_KEY}" "${REMOTE_TARGET}" \
|
||||
"set -e; umask 077; cat > ${quoted_remote_secret_temp_path}; chmod 600 ${quoted_remote_secret_temp_path}; mv -f ${quoted_remote_secret_temp_path} ${quoted_remote_secret_path}" \
|
||||
<"${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[deploy:rust] 完成: ${TARGET_DIR}"
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
set -euo pipefail
|
||||
|
||||
MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}"
|
||||
MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}"
|
||||
|
||||
if [[ -f "${MAINTENANCE_FILE}" ]]; then
|
||||
rm -f "${MAINTENANCE_FILE}"
|
||||
rm -f -- "${MAINTENANCE_FILE}"
|
||||
echo "[maintenance] 已退出维护模式: ${MAINTENANCE_FILE}"
|
||||
else
|
||||
echo "[maintenance] 当前未处于维护模式: ${MAINTENANCE_FILE}"
|
||||
fi
|
||||
|
||||
if [[ -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ]]; then
|
||||
rm -f -- "${MAINTENANCE_PAGE_FILE}"
|
||||
echo "[maintenance] 已清理本次运行态公告页: ${MAINTENANCE_PAGE_FILE}"
|
||||
fi
|
||||
|
||||
@@ -3,13 +3,59 @@
|
||||
set -euo pipefail
|
||||
|
||||
MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}"
|
||||
REASON="${*:-manual}"
|
||||
MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}"
|
||||
PAGE_SOURCE=""
|
||||
REASON_PARTS=()
|
||||
|
||||
mkdir -p "$(dirname "${MAINTENANCE_FILE}")"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--page-file)
|
||||
PAGE_SOURCE="${2:?缺少 --page-file 的值}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "用法: $0 [--page-file <临时公告 HTML>] [维护原因]"
|
||||
exit 0
|
||||
;;
|
||||
--*)
|
||||
echo "[maintenance] 未知参数: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
REASON_PARTS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
REASON="${REASON_PARTS[*]:-manual}"
|
||||
|
||||
if [[ -n "${PAGE_SOURCE}" && ( ! -f "${PAGE_SOURCE}" || ! -s "${PAGE_SOURCE}" ) ]]; then
|
||||
echo "[maintenance] 临时公告页不存在或为空: ${PAGE_SOURCE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${MAINTENANCE_FILE}")" "$(dirname "${MAINTENANCE_PAGE_FILE}")"
|
||||
|
||||
if [[ -n "${PAGE_SOURCE}" ]]; then
|
||||
page_temp="$(mktemp "${MAINTENANCE_PAGE_FILE}.tmp.XXXXXX")"
|
||||
trap 'rm -f "${page_temp:-}" "${marker_temp:-}"' EXIT
|
||||
install -m 0644 -- "${PAGE_SOURCE}" "${page_temp}"
|
||||
mv -fT -- "${page_temp}" "${MAINTENANCE_PAGE_FILE}"
|
||||
page_temp=""
|
||||
echo "[maintenance] 已安装本次运行态公告页: ${MAINTENANCE_PAGE_FILE}"
|
||||
elif [[ ! -f "${MAINTENANCE_FILE}" && ( -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ) ]]; then
|
||||
rm -f -- "${MAINTENANCE_PAGE_FILE}"
|
||||
fi
|
||||
|
||||
marker_temp="$(mktemp "${MAINTENANCE_FILE}.tmp.XXXXXX")"
|
||||
{
|
||||
printf "enabled_at=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf "reason=%s\n" "${REASON}"
|
||||
} >"${MAINTENANCE_FILE}"
|
||||
} >"${marker_temp}"
|
||||
|
||||
chmod 0644 "${MAINTENANCE_FILE}"
|
||||
chmod 0644 "${marker_temp}"
|
||||
mv -fT -- "${marker_temp}" "${MAINTENANCE_FILE}"
|
||||
marker_temp=""
|
||||
trap - EXIT
|
||||
echo "[maintenance] 已进入维护模式: ${MAINTENANCE_FILE}"
|
||||
|
||||
@@ -5,13 +5,14 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--pingora-service genarrative-pingora-gateway.service] [--require-pingora-gateway] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
|
||||
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--pingora-service genarrative-pingora-gateway.service] [--require-pingora-gateway] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--worker-env-file /etc/genarrative/external-generation-worker.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] [--keep-maintenance-mode]
|
||||
|
||||
说明:
|
||||
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
|
||||
默认同时重启外部生成 worker controller 和已加载的 worker 实例;未启用 worker 单元时会自动跳过。
|
||||
若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
|
||||
若发布包包含 pingora-gateway,或传入 --require-pingora-gateway,部署脚本会要求 release manifest、二进制与 checksum 一致,再在 current 链接切换后先复核 systemd/env 仍是本机高端口 shadow 配置,启动或重启 Pingora 影子服务并复核 active。
|
||||
默认在 readiness 通过后退出维护模式;传入 --keep-maintenance-mode 时保留维护文件,供人工验收后再恢复公网。
|
||||
current 链接切换前失败时会退出本次打开的维护模式;current 链接切换后失败时保留维护模式,避免暴露半发布版本。
|
||||
EOF
|
||||
}
|
||||
@@ -183,10 +184,56 @@ ensure_env_value() {
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[production-api-deploy] 补齐 api-server 环境变量: ${key} -> ${file_path}"
|
||||
echo "[production-api-deploy] 补齐运行态环境变量: ${key} -> ${file_path}"
|
||||
write_env_value "${file_path}" "${key}" "${default_value}"
|
||||
}
|
||||
|
||||
ensure_env_value_migrates_old_default() {
|
||||
local file_path="$1"
|
||||
local key="$2"
|
||||
local old_default="$3"
|
||||
local new_default="$4"
|
||||
local current_value
|
||||
|
||||
current_value="$(read_env_value "${file_path}" "${key}")"
|
||||
if [[ -z "${current_value}" ]]; then
|
||||
ensure_env_value "${file_path}" "${key}" "${new_default}"
|
||||
return
|
||||
fi
|
||||
if [[ "${current_value}" != "${old_default}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[production-api-deploy] 迁移运行态环境变量旧默认值: ${key} ${old_default} -> ${new_default} (${file_path})"
|
||||
write_env_value "${file_path}" "${key}" "${new_default}"
|
||||
}
|
||||
|
||||
ensure_runtime_bootstrap_secret_file_env() {
|
||||
local file_path="$1"
|
||||
local key="GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE"
|
||||
local direct_key="GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET"
|
||||
local canonical_path="/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt"
|
||||
local current_value direct_value
|
||||
|
||||
direct_value="$(read_env_value "${file_path}" "${direct_key}")"
|
||||
if [[ -n "${direct_value}" ]]; then
|
||||
unset direct_value
|
||||
echo "[production-api-deploy] ${file_path} 不得保存 ${direct_key} 明文;生产环境只允许使用 ${key}。" >&2
|
||||
exit 1
|
||||
fi
|
||||
unset direct_value
|
||||
|
||||
current_value="$(read_env_value "${file_path}" "${key}")"
|
||||
if [[ -z "${current_value}" ]]; then
|
||||
ensure_env_value "${file_path}" "${key}" "${canonical_path}"
|
||||
return
|
||||
fi
|
||||
if [[ "${current_value}" != "${canonical_path}" ]]; then
|
||||
echo "[production-api-deploy] ${key} 必须使用 Stdb publish 同步写入的固定路径 ${canonical_path}: ${file_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_privileged() {
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
"$@"
|
||||
@@ -215,6 +262,24 @@ ensure_runtime_dir() {
|
||||
run_privileged install -d -o genarrative -g genarrative -m "${mode}" "${path}"
|
||||
}
|
||||
|
||||
migrate_legacy_editor_generation_pricing_override() {
|
||||
local current_link="$1"
|
||||
local target_dir="/var/lib/genarrative/editor-generation-pricing"
|
||||
local target_file="${target_dir}/editor-generation-pricing.override.json"
|
||||
local legacy_file="${current_link}/.app/editor-generation-pricing.override.json"
|
||||
|
||||
if [[ -f "${target_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
if [[ ! -f "${legacy_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[production-api-deploy] 迁移旧模型定价 override 到运行态目录: ${legacy_file} -> ${target_file}"
|
||||
ensure_runtime_dir "${target_dir}" "0750"
|
||||
run_privileged install -o genarrative -g genarrative -m 0640 "${legacy_file}" "${target_file}"
|
||||
}
|
||||
|
||||
ensure_runtime_env_and_dirs() {
|
||||
local api_env_file="$1"
|
||||
local tracking_enabled tracking_outbox_dir wallet_refund_enabled wallet_refund_outbox_dir
|
||||
@@ -232,6 +297,13 @@ ensure_runtime_env_and_dirs() {
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE" "100"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS" "1000"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES" "67108864"
|
||||
ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
|
||||
ensure_runtime_bootstrap_secret_file_env "${api_env_file}"
|
||||
ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300"
|
||||
|
||||
tracking_enabled="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED")"
|
||||
tracking_outbox_dir="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR")"
|
||||
@@ -245,6 +317,23 @@ ensure_runtime_env_and_dirs() {
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_worker_runtime_env_defaults() {
|
||||
local worker_env_file="$1"
|
||||
|
||||
if [[ -z "${worker_env_file}" ]]; then
|
||||
return
|
||||
fi
|
||||
if [[ ! -f "${worker_env_file}" ]]; then
|
||||
echo "[production-api-deploy] worker 环境文件不存在,跳过运行态默认值补齐: ${worker_env_file}"
|
||||
return
|
||||
fi
|
||||
|
||||
ensure_env_value_migrates_old_default "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
|
||||
ensure_env_value "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
|
||||
ensure_env_value "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
|
||||
ensure_runtime_bootstrap_secret_file_env "${worker_env_file}"
|
||||
}
|
||||
|
||||
extract_pingora_env_files_from_unit() {
|
||||
local service_name="$1"
|
||||
local unit_content
|
||||
@@ -277,19 +366,25 @@ extract_pingora_env_files_from_unit() {
|
||||
|
||||
find_pingora_gateway_env_file() {
|
||||
local service_name="$1"
|
||||
local env_file listen
|
||||
local env_file listen unit_env_files
|
||||
|
||||
while IFS= read -r env_file; do
|
||||
if [[ "${env_file}" != /* ]]; then
|
||||
echo "[production-api-deploy] Pingora EnvironmentFile 必须使用绝对路径: ${env_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
listen="$(read_env_value "${env_file}" "GENARRATIVE_PINGORA_GATEWAY_LISTEN")"
|
||||
if [[ -n "${listen}" ]]; then
|
||||
printf "%s\n" "${env_file}"
|
||||
return
|
||||
fi
|
||||
done < <(extract_pingora_env_files_from_unit "${service_name}")
|
||||
if ! unit_env_files="$(extract_pingora_env_files_from_unit "${service_name}")"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "${unit_env_files}" ]]; then
|
||||
while IFS= read -r env_file; do
|
||||
if [[ "${env_file}" != /* ]]; then
|
||||
echo "[production-api-deploy] Pingora EnvironmentFile 必须使用绝对路径: ${env_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
listen="$(read_env_value "${env_file}" "GENARRATIVE_PINGORA_GATEWAY_LISTEN")"
|
||||
if [[ -n "${listen}" ]]; then
|
||||
printf "%s\n" "${env_file}"
|
||||
return
|
||||
fi
|
||||
done <<< "${unit_env_files}"
|
||||
fi
|
||||
|
||||
echo "[production-api-deploy] Pingora systemd 配置缺少包含 GENARRATIVE_PINGORA_GATEWAY_LISTEN 的 EnvironmentFile: ${service_name}" >&2
|
||||
exit 1
|
||||
@@ -321,7 +416,9 @@ check_pingora_shadow_service_config() {
|
||||
local service_name="$1"
|
||||
local env_file
|
||||
|
||||
env_file="$(find_pingora_gateway_env_file "${service_name}")"
|
||||
if ! env_file="$(find_pingora_gateway_env_file "${service_name}")"; then
|
||||
return 1
|
||||
fi
|
||||
require_pingora_shadow_env "${env_file}"
|
||||
printf "%s\n" "${env_file}"
|
||||
}
|
||||
@@ -585,12 +682,15 @@ WORKER_SERVICE_PATTERN="genarrative-external-generation-worker@*.service"
|
||||
WORKER_CONTROLLER_SERVICE="genarrative-external-generation-controller.service"
|
||||
HEALTH_URL="http://127.0.0.1:8082/readyz"
|
||||
API_ENV_FILE="/etc/genarrative/api-server.env"
|
||||
WORKER_ENV_FILE="/etc/genarrative/external-generation-worker.env"
|
||||
DATABASE=""
|
||||
SPACETIME_SERVER_URL=""
|
||||
DEPLOY_COMPLETED=0
|
||||
PINGORA_INCLUDED=0
|
||||
REQUIRE_PINGORA_GATEWAY=0
|
||||
KEEP_MAINTENANCE_MODE=0
|
||||
MAINTENANCE_ENABLED_BY_DEPLOY=0
|
||||
MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}"
|
||||
CURRENT_LINK_SWITCHED=0
|
||||
RELEASE_DIR=""
|
||||
STAGING_RELEASE_DIR=""
|
||||
@@ -629,6 +729,10 @@ while [[ $# -gt 0 ]]; do
|
||||
REQUIRE_PINGORA_GATEWAY=1
|
||||
shift
|
||||
;;
|
||||
--keep-maintenance-mode)
|
||||
KEEP_MAINTENANCE_MODE=1
|
||||
shift
|
||||
;;
|
||||
--worker-service-pattern)
|
||||
WORKER_SERVICE_PATTERN="${2:?缺少 --worker-service-pattern 的值}"
|
||||
shift 2
|
||||
@@ -653,6 +757,10 @@ while [[ $# -gt 0 ]]; do
|
||||
API_ENV_FILE="${2:?缺少 --api-env-file 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--worker-env-file)
|
||||
WORKER_ENV_FILE="${2:?缺少 --worker-env-file 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--database)
|
||||
DATABASE="${2:?缺少 --database 的值}"
|
||||
shift 2
|
||||
@@ -673,6 +781,9 @@ require_argument "${SOURCE_DIR}" "--source-dir"
|
||||
require_absolute_path "${RELEASE_ROOT}" "--release-root"
|
||||
require_absolute_path "${CURRENT_LINK}" "--current-link"
|
||||
require_absolute_path "${API_ENV_FILE}" "--api-env-file"
|
||||
if [[ -n "${WORKER_ENV_FILE}" ]]; then
|
||||
require_absolute_path "${WORKER_ENV_FILE}" "--worker-env-file"
|
||||
fi
|
||||
|
||||
if [[ -n "${DATABASE}" ]]; then
|
||||
validate_spacetime_database_name "${DATABASE}"
|
||||
@@ -743,8 +854,12 @@ on_exit() {
|
||||
|
||||
trap on_exit EXIT
|
||||
|
||||
if [[ ! -f "${MAINTENANCE_FILE}" ]]; then
|
||||
MAINTENANCE_ENABLED_BY_DEPLOY=1
|
||||
else
|
||||
echo "[production-api-deploy] 继承已有维护模式;部署失败时不得误退出上游维护窗口: ${MAINTENANCE_FILE}"
|
||||
fi
|
||||
bash "${SCRIPT_DIR}/maintenance-on.sh" "api deploy ${VERSION}"
|
||||
MAINTENANCE_ENABLED_BY_DEPLOY=1
|
||||
|
||||
echo "[production-api-deploy] 校验 api-server"
|
||||
(
|
||||
@@ -781,6 +896,9 @@ if [[ -f "${SOURCE_DIR}/pingora-gateway" ]]; then
|
||||
fi
|
||||
|
||||
BACKUP_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
|
||||
API_DEPLOY_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/production-api-deploy.sh"
|
||||
MAINTENANCE_ON_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/maintenance-on.sh"
|
||||
MAINTENANCE_OFF_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/maintenance-off.sh"
|
||||
HEALTH_PATROL_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/ops/production-health-patrol.mjs"
|
||||
PINGORA_CURRENT_RELEASE_AUDIT_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/ops/pingora-current-release-audit.mjs"
|
||||
PINGORA_DIRECT_REHEARSAL_STATUS_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/ops/pingora-direct-rehearsal-status.mjs"
|
||||
@@ -807,6 +925,19 @@ SYSTEMD_DEPLOY_DIR_SOURCE="${SOURCE_DIR}/deploy/systemd"
|
||||
NGINX_DEPLOY_DIR_SOURCE="${SOURCE_DIR}/deploy/nginx"
|
||||
ENV_DEPLOY_DIR_SOURCE="${SOURCE_DIR}/deploy/env"
|
||||
mkdir -p "${RELEASE_CONTENT_DIR}/scripts" "${RELEASE_CONTENT_DIR}/scripts/deploy" "${RELEASE_CONTENT_DIR}/scripts/ops" "${RELEASE_CONTENT_DIR}/deploy"
|
||||
if [[ ! -f "${API_DEPLOY_SCRIPT_SOURCE}" ]]; then
|
||||
echo "[production-api-deploy] 发布产物缺少 API 部署脚本: ${SOURCE_DIR}/scripts/deploy/production-api-deploy.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "${API_DEPLOY_SCRIPT_SOURCE}" "${RELEASE_CONTENT_DIR}/scripts/deploy/production-api-deploy.sh"
|
||||
chmod 0755 "${RELEASE_CONTENT_DIR}/scripts/deploy/production-api-deploy.sh"
|
||||
if [[ ! -f "${MAINTENANCE_ON_SCRIPT_SOURCE}" || ! -f "${MAINTENANCE_OFF_SCRIPT_SOURCE}" ]]; then
|
||||
echo "[production-api-deploy] 发布产物缺少维护模式脚本: ${SOURCE_DIR}/scripts/deploy" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "${MAINTENANCE_ON_SCRIPT_SOURCE}" "${RELEASE_CONTENT_DIR}/scripts/deploy/maintenance-on.sh"
|
||||
cp "${MAINTENANCE_OFF_SCRIPT_SOURCE}" "${RELEASE_CONTENT_DIR}/scripts/deploy/maintenance-off.sh"
|
||||
chmod 0755 "${RELEASE_CONTENT_DIR}/scripts/deploy/maintenance-on.sh" "${RELEASE_CONTENT_DIR}/scripts/deploy/maintenance-off.sh"
|
||||
if [[ ! -f "${BACKUP_SCRIPT_SOURCE}" ]]; then
|
||||
echo "[production-api-deploy] 发布产物缺少数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&2
|
||||
exit 1
|
||||
@@ -979,6 +1110,8 @@ if [[ -n "${SPACETIME_SERVER_URL}" ]]; then
|
||||
fi
|
||||
|
||||
ensure_runtime_env_and_dirs "${API_ENV_FILE}"
|
||||
ensure_worker_runtime_env_defaults "${WORKER_ENV_FILE}"
|
||||
migrate_legacy_editor_generation_pricing_override "${CURRENT_LINK}"
|
||||
|
||||
if [[ "${PINGORA_INCLUDED}" -eq 1 ]]; then
|
||||
PINGORA_SHADOW_ENV_FILE="$(check_pingora_shadow_service_config "${PINGORA_SERVICE_NAME}")"
|
||||
@@ -1009,8 +1142,12 @@ wait_for_worker_controller_service "${WORKER_CONTROLLER_SERVICE}"
|
||||
|
||||
echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
|
||||
for _ in {1..30}; do
|
||||
if curl -fsS "${HEALTH_URL}" >/dev/null; then
|
||||
bash "${SCRIPT_DIR}/maintenance-off.sh"
|
||||
if curl -fsS --max-time 2 "${HEALTH_URL}" >/dev/null; then
|
||||
if [[ "${KEEP_MAINTENANCE_MODE}" -eq 1 ]]; then
|
||||
echo "[production-api-deploy] readiness 通过,按参数保持维护模式: ${MAINTENANCE_FILE}"
|
||||
else
|
||||
bash "${SCRIPT_DIR}/maintenance-off.sh"
|
||||
fi
|
||||
DEPLOY_COMPLETED=1
|
||||
echo "[production-api-deploy] 完成: ${RELEASE_DIR}/api-server"
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
callSpacetimeProcedureViaCli,
|
||||
ensureProcedureOk,
|
||||
validateSpacetimeDatabaseName,
|
||||
} from '../spacetime-migration-common.mjs';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const PROCEDURE = 'rotate_editor_generation_runtime_service_identity_and_return';
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
'用法: production-runtime-writer-identity-rotate.mjs --database <database> --server-url <http(s)://host:port> --operator-identity <64-hex> --operator-user-id <user-id> --next-writer-identity <64-hex> --confirm-next-writer-identity <64-hex> --note <audit-note>',
|
||||
);
|
||||
}
|
||||
|
||||
function readValue(argv, index, name) {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${name} 缺少参数值。`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeIdentity(value, name) {
|
||||
const identity = value.trim().replace(/^0x/u, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/u.test(identity)) {
|
||||
throw new Error(`${name} 必须是 64 位十六进制 SpacetimeDB identity。`);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function readCurrentCliIdentity() {
|
||||
const output = await new Promise((resolve, reject) => {
|
||||
const child = spawn('spacetime', ['login', 'show'], {
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let text = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
text += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
text += chunk.toString();
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`spacetime login show 被信号中断: ${signal}`));
|
||||
} else if (code !== 0) {
|
||||
reject(new Error(`无法读取当前 SpacetimeDB CLI identity,退出码 ${code}`));
|
||||
} else {
|
||||
resolve(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
const identities = [...output.matchAll(/\b[0-9a-f]{64}\b/giu)].map((match) =>
|
||||
match[0].toLowerCase(),
|
||||
);
|
||||
if (identities.length !== 1) {
|
||||
throw new Error('spacetime login show 未返回唯一的当前 CLI identity。');
|
||||
}
|
||||
return identities[0];
|
||||
}
|
||||
|
||||
function parseOptions(argv) {
|
||||
const options = {
|
||||
database: '',
|
||||
serverUrl: '',
|
||||
operatorIdentity: '',
|
||||
operatorUserId: '',
|
||||
nextWriterIdentity: '',
|
||||
confirmNextWriterIdentity: '',
|
||||
note: '',
|
||||
passthrough: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === '--database') {
|
||||
options.database = readValue(argv, index, arg);
|
||||
} else if (arg === '--server-url') {
|
||||
options.serverUrl = readValue(argv, index, arg);
|
||||
} else if (arg === '--operator-identity') {
|
||||
options.operatorIdentity = readValue(argv, index, arg);
|
||||
} else if (arg === '--operator-user-id') {
|
||||
options.operatorUserId = readValue(argv, index, arg);
|
||||
} else if (arg === '--next-writer-identity') {
|
||||
options.nextWriterIdentity = readValue(argv, index, arg);
|
||||
} else if (arg === '--confirm-next-writer-identity') {
|
||||
options.confirmNextWriterIdentity = readValue(argv, index, arg);
|
||||
} else if (arg === '--note') {
|
||||
options.note = readValue(argv, index, arg);
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = parseOptions(process.argv.slice(2));
|
||||
validateSpacetimeDatabaseName(options.database);
|
||||
if (!/^https?:\/\/[A-Za-z0-9.-]+(?::[0-9]{1,5})?$/u.test(options.serverUrl)) {
|
||||
throw new Error('--server-url 必须是无路径、无查询参数的显式 http(s) URL。');
|
||||
}
|
||||
|
||||
const operatorIdentity = normalizeIdentity(options.operatorIdentity, '--operator-identity');
|
||||
const nextWriterIdentity = normalizeIdentity(
|
||||
options.nextWriterIdentity,
|
||||
'--next-writer-identity',
|
||||
);
|
||||
const confirmedIdentity = normalizeIdentity(
|
||||
options.confirmNextWriterIdentity,
|
||||
'--confirm-next-writer-identity',
|
||||
);
|
||||
if (nextWriterIdentity !== confirmedIdentity) {
|
||||
throw new Error('--confirm-next-writer-identity 必须与 --next-writer-identity 完全一致。');
|
||||
}
|
||||
if (nextWriterIdentity === operatorIdentity) {
|
||||
throw new Error('runtime writer identity 不能设为 migration operator identity。');
|
||||
}
|
||||
|
||||
const currentCliIdentity = await readCurrentCliIdentity();
|
||||
if (operatorIdentity !== currentCliIdentity) {
|
||||
throw new Error('--operator-identity 必须与当前 SpacetimeDB CLI 登录 identity 一致。');
|
||||
}
|
||||
|
||||
const operatorUserId = options.operatorUserId.trim();
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/u.test(operatorUserId)) {
|
||||
throw new Error('--operator-user-id 格式不合法。');
|
||||
}
|
||||
const note = options.note.trim();
|
||||
if (!note || note.length > 500) {
|
||||
throw new Error('--note 必填且不能超过 500 个字符。');
|
||||
}
|
||||
|
||||
const result = await callSpacetimeProcedureViaCli(
|
||||
{ database: options.database, serverUrl: options.serverUrl, passthrough: [] },
|
||||
PROCEDURE,
|
||||
{
|
||||
next_writer_identity_hex: nextWriterIdentity,
|
||||
operator_user_id: operatorUserId,
|
||||
note,
|
||||
},
|
||||
);
|
||||
ensureProcedureOk(result);
|
||||
console.log(
|
||||
`[runtime-writer-identity-rotation] 已完成 identity rotation: database=${options.database}, operator_identity=${operatorIdentity}, next_writer_identity=${nextWriterIdentity}; 审计记录已写入 editor_generation_runtime_identity_rotation。`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[runtime-writer-identity-rotation] 操作失败;必须使用已授权的 migration operator CLI 登录态。${error instanceof Error ? ` ${error.message}` : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+665
-152
File diff suppressed because it is too large
Load Diff
+690
-130
File diff suppressed because it is too large
Load Diff
@@ -427,12 +427,60 @@ ensure_env_value() {
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[server-provision] 补齐 api-server 环境变量: ${key} -> ${file}"
|
||||
echo "[server-provision] 补齐运行态环境变量: ${key} -> ${file}"
|
||||
if [[ "${DRY_RUN}" != "true" ]]; then
|
||||
write_env_value "${file}" "${key}" "${default_value}"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_env_value_migrates_old_default() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local old_default="$3"
|
||||
local new_default="$4"
|
||||
local current_value
|
||||
|
||||
current_value="$(read_env_value "${file}" "${key}")"
|
||||
if [[ -z "${current_value}" ]]; then
|
||||
ensure_env_value "${file}" "${key}" "${new_default}"
|
||||
return
|
||||
fi
|
||||
if [[ "${current_value}" != "${old_default}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[server-provision] 迁移运行态环境变量旧默认值: ${key} ${old_default} -> ${new_default} (${file})"
|
||||
if [[ "${DRY_RUN}" != "true" ]]; then
|
||||
write_env_value "${file}" "${key}" "${new_default}"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_runtime_bootstrap_secret_file_env() {
|
||||
local file="$1"
|
||||
local key="GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE"
|
||||
local direct_key="GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET"
|
||||
local canonical_path="/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt"
|
||||
local current_value direct_value
|
||||
|
||||
direct_value="$(read_env_value "${file}" "${direct_key}")"
|
||||
if [[ -n "${direct_value}" ]]; then
|
||||
unset direct_value
|
||||
echo "[server-provision] ${file} 不得保存 ${direct_key} 明文;生产环境只允许使用 ${key}。" >&2
|
||||
exit 1
|
||||
fi
|
||||
unset direct_value
|
||||
|
||||
current_value="$(read_env_value "${file}" "${key}")"
|
||||
if [[ -z "${current_value}" ]]; then
|
||||
ensure_env_value "${file}" "${key}" "${canonical_path}"
|
||||
return
|
||||
fi
|
||||
if [[ "${current_value}" != "${canonical_path}" ]]; then
|
||||
echo "[server-provision] ${key} 必须使用 Stdb publish 同步写入的固定路径 ${canonical_path}: ${file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_api_runtime_env_defaults() {
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "+ ensure api-server runtime env defaults in ${API_ENV_FILE}"
|
||||
@@ -454,6 +502,29 @@ ensure_api_runtime_env_defaults() {
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE" "100"
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS" "1000"
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES" "67108864"
|
||||
ensure_env_value_migrates_old_default "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
|
||||
ensure_runtime_bootstrap_secret_file_env "${API_ENV_FILE}"
|
||||
ensure_env_value_migrates_old_default "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000"
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3"
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300"
|
||||
}
|
||||
|
||||
ensure_worker_runtime_env_defaults() {
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "+ ensure external-generation worker runtime env defaults in ${WORKER_ENV_FILE}"
|
||||
return
|
||||
fi
|
||||
if [[ ! -f "${WORKER_ENV_FILE}" ]]; then
|
||||
echo "[server-provision] worker 环境文件不存在,无法补齐运行态变量: ${WORKER_ENV_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_env_value_migrates_old_default "${WORKER_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
|
||||
ensure_env_value "${WORKER_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
|
||||
ensure_env_value "${WORKER_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
|
||||
ensure_runtime_bootstrap_secret_file_env "${WORKER_ENV_FILE}"
|
||||
}
|
||||
|
||||
parse_json_string_field() {
|
||||
@@ -887,7 +958,7 @@ echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_conf
|
||||
run_cmd id
|
||||
require_root_for_real_provision
|
||||
install_nginx_brotli_modules
|
||||
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /etc/genarrative/pingora /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/wallet-refund-outbox /var/lib/genarrative/database-backups /var/lib/genarrative/health-patrol /var/log/genarrative
|
||||
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /etc/genarrative/pingora /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/wallet-refund-outbox /var/lib/genarrative/editor-generation-pricing /var/lib/genarrative/spacetime /var/lib/genarrative/database-backups /var/lib/genarrative/health-patrol /var/log/genarrative
|
||||
|
||||
if ! id spacetimedb >/dev/null 2>&1; then
|
||||
run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb
|
||||
@@ -904,6 +975,15 @@ fi
|
||||
run_cmd chown -R spacetimedb:spacetimedb "${SPACETIME_ROOT}"
|
||||
run_cmd chown -R genarrative:genarrative /opt/genarrative /var/lib/genarrative /srv/genarrative
|
||||
run_cmd chown -R genarrative:genarrative /var/log/genarrative
|
||||
if [[ -L /var/lib/genarrative/spacetime || -L /var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt ]]; then
|
||||
echo "[server-provision] 运行时服务身份引导密钥路径不能是符号链接。" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_cmd install -d -o root -g genarrative -m 0750 /var/lib/genarrative/spacetime
|
||||
if [[ -f /var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt ]]; then
|
||||
run_cmd chown root:genarrative /var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt
|
||||
run_cmd chmod 0440 /var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt
|
||||
fi
|
||||
install_genarrative_openssl_runtime
|
||||
|
||||
if [[ ! -x "${SPACETIME_BIN_SOURCE}" ]]; then
|
||||
@@ -964,6 +1044,7 @@ if [[ ! -f "${WORKER_ENV_FILE}" ]]; then
|
||||
else
|
||||
echo "[server-provision] 已存在 worker 环境文件,保留不覆盖: ${WORKER_ENV_FILE}"
|
||||
fi
|
||||
ensure_worker_runtime_env_defaults
|
||||
|
||||
if [[ ! -f "${CONTROLLER_ENV_FILE}" ]]; then
|
||||
echo "+ create ${CONTROLLER_ENV_FILE} from example"
|
||||
|
||||
@@ -9,7 +9,7 @@ OTELCOL_DOWNLOAD_ROOT="${OTELCOL_DOWNLOAD_ROOT:-https://github.com/open-telemetr
|
||||
OTELCOL_ARCHIVE_PATH="${OTELCOL_ARCHIVE_PATH:-}"
|
||||
OTELCOL_TARGET_BIN="${OTELCOL_TARGET_BIN:-/usr/local/bin/otelcol-contrib}"
|
||||
SPACETIME_INSTALLER_URL="${SPACETIME_INSTALLER_URL:-https://install.spacetimedb.com}"
|
||||
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0}"
|
||||
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.6.0}"
|
||||
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}"
|
||||
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}"
|
||||
SPACETIME_EXPECTED_VERSION="${SPACETIME_EXPECTED_VERSION:-}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {
|
||||
callSpacetimeProcedureViaCli,
|
||||
ensureProcedureOk,
|
||||
parseArgs,
|
||||
resolveBootstrapSecret,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
try {
|
||||
@@ -14,7 +15,7 @@ try {
|
||||
}
|
||||
|
||||
const input = {
|
||||
bootstrap_secret: options.bootstrapSecret || '',
|
||||
bootstrap_secret: await resolveBootstrapSecret(options),
|
||||
operator_identity_hex: options.operatorIdentity,
|
||||
note: options.note || '',
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user