合并 master 最新更新
Project CI / Frontend tests (push) Failing after 24s
Project CI / Repository checks (push) Failing after 43s
Project CI / Backend tests (push) Successful in 3m5s
Project CI / Native shell tests (push) Failing after 6m49s

合入 master 的 BgFilter、CI、运维与现役平台改造。

保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。

修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。

同步冲突文档、锁文件和开发脚本。
This commit is contained in:
AIGameCreator App
2026-07-27 18:28:26 +08:00
1304 changed files with 170414 additions and 36096 deletions
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import { afterEach, describe, test } from 'node:test';
import {
buildIsolatedWorkerEnv,
createProviderGate,
createProviderSequenceBehavior,
SMOKE_PNG_BYTES,
startMockBgfilterProvider,
} from './bgfilter-worker-load-smoke.mjs';
const providers = [];
afterEach(async () => {
await Promise.all(providers.splice(0).map((provider) => provider.close()));
});
describe('bgfilter worker smoke harness', () => {
test('worker 环境不继承真实服务密钥并固定使用假 OSS 配置', () => {
const env = buildIsolatedWorkerEnv({
processEnv: {
ALIYUN_OSS_ACCESS_KEY_SECRET: 'real-oss-secret',
GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'real-internal-token',
GENARRATIVE_EDITOR_BGFILTER_TOKEN: 'real-provider-token',
PATH: '/safe/bin',
VECTOR_ENGINE_API_KEY: 'real-vector-secret',
},
providerBaseUrl: 'http://127.0.0.1:19001',
tempRoot: '/tmp/bgfilter-load-smoke-test',
token: 'ephemeral-test-token',
workerPort: 19002,
});
assert.equal(env.PATH, '/safe/bin');
assert.equal(
env.GENARRATIVE_BGFILTER_INTERNAL_TOKEN,
'ephemeral-test-token',
);
assert.equal(env.ALIYUN_OSS_ENDPOINT, 'oss-cn-shanghai.invalid');
assert.notEqual(env.ALIYUN_OSS_ACCESS_KEY_SECRET, 'real-oss-secret');
assert.equal(env.GENARRATIVE_EDITOR_BGFILTER_TOKEN, undefined);
assert.equal(env.VECTOR_ENGINE_API_KEY, undefined);
assert.ok(!Object.values(env).includes('real-internal-token'));
assert.ok(!Object.values(env).includes('real-provider-token'));
assert.ok(!Object.values(env).includes('real-vector-secret'));
});
test('loopback mock 完整读取 multipart 后记录并发并返回合法 PNG 字节', async () => {
const provider = await startMockBgfilterProvider({ delayMs: 25 });
providers.push(provider);
const request = multipartFixture();
const responses = await Promise.all([
postMultipart(provider.baseUrl, request),
postMultipart(provider.baseUrl, request),
]);
for (const response of responses) {
assert.equal(response.statusCode, 200);
assert.equal(response.contentType, 'image/png');
assert.ok(response.body.equals(SMOKE_PNG_BYTES));
}
const stats = provider.snapshot();
assert.equal(stats.active, 0);
assert.equal(stats.peak, 2);
assert.equal(stats.requests, 2);
assert.deepEqual(stats.violations, []);
assert.equal(stats.timeline.filter((event) => event.event === 'start').length, 2);
assert.equal(stats.timeline.filter((event) => event.event === 'finish').length, 2);
});
test('provider gate 与 sequence behavior 生成无重叠 timeline', async () => {
const gate = createProviderGate();
const provider = await startMockBgfilterProvider({
behavior: createProviderSequenceBehavior([503, 200]),
delayMs: 5,
gate,
});
providers.push(provider);
const request = multipartFixture();
let firstSettled = false;
const first = postMultipart(provider.baseUrl, request).finally(() => {
firstSettled = true;
});
await provider.waitFor((stats) => stats.active === 1, { timeoutMs: 1_000 });
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(firstSettled, false);
gate.release();
assert.equal((await first).statusCode, 503);
assert.equal((await postMultipart(provider.baseUrl, request)).statusCode, 200);
const stats = provider.snapshot();
assert.equal(stats.peak, 1);
assert.deepEqual(
stats.timeline.map((event) => [
event.attempt,
event.event,
event.statusCode ?? null,
]),
[
[1, 'start', null],
[1, 'finish', 503],
[2, 'start', null],
[2, 'finish', 200],
],
);
});
test('provider 可在成功响应 body 中途 reset 并记录完成类型', async () => {
const provider = await startMockBgfilterProvider({
behavior: createProviderSequenceBehavior([
{ resetMidBody: true, statusCode: 200 },
]),
delayMs: 5,
});
providers.push(provider);
await assert.rejects(postMultipart(provider.baseUrl, multipartFixture()));
const stats = provider.snapshot();
assert.equal(stats.active, 0);
assert.equal(stats.peak, 1);
assert.equal(stats.requests, 1);
assert.deepEqual(stats.violations, []);
assert.equal(stats.timeline[1]?.completion, 'mid_body_reset');
});
});
function multipartFixture() {
const boundary = 'bgfilter-load-smoke-boundary';
const body = Buffer.from(
[
`--${boundary}`,
'Content-Disposition: form-data; name="image_url"',
'',
'https://example.invalid/source.png',
`--${boundary}`,
'Content-Disposition: form-data; name="seg_model"',
'',
'birefnet',
`--${boundary}`,
'Content-Disposition: form-data; name="background_mode"',
'',
'complex',
`--${boundary}`,
'Content-Disposition: form-data; name="cross_check"',
'',
'off',
`--${boundary}--`,
'',
].join('\r\n'),
'utf8',
);
return { body, boundary };
}
function postMultipart(baseUrl, { body, boundary }) {
return new Promise((resolve, reject) => {
const request = http.request(
`${baseUrl}/remove-background`,
{
agent: false,
headers: {
'Content-Length': String(body.length),
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
method: 'POST',
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
response.once('aborted', () => {
reject(new Error('mock provider 响应在 body 中途中止'));
});
response.once('error', reject);
response.once('end', () => {
resolve({
body: Buffer.concat(chunks),
contentType: String(response.headers['content-type'] ?? ''),
statusCode: response.statusCode ?? 0,
});
});
},
);
request.once('error', reject);
request.end(body);
});
}
+1 -1
View File
@@ -20,7 +20,7 @@ const repoRoot = path.resolve(
'..',
);
const database = 'admin-account-smoke';
const expectedSpacetimeVersion = '2.6.1';
const expectedSpacetimeVersion = '2.7.0';
const commandTimeoutMs = 5 * 60 * 1000;
function assert(condition, message) {
+1 -1
View File
@@ -31,7 +31,7 @@ console.log('[api-server-env] 认证短信配置检查');
printStatus('SMS_AUTH_ENABLED', env.SMS_AUTH_ENABLED === 'true');
printStatus('SMS_AUTH_PROVIDER', hasValue(env.SMS_AUTH_PROVIDER));
console.log('[api-server-env] 拼图真实生成配置检查');
console.log('[api-server-env] 编辑器真实生成配置检查');
for (const key of REQUIRED_FOR_PUZZLE_GENERATION) {
const present = hasValue(env[key]);
printStatus(key, present);
+131 -4
View File
@@ -6,6 +6,7 @@ import {chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync,
import {tmpdir} from 'node:os';
import path from 'node:path';
import {Readable} from 'node:stream';
import {gunzipSync, gzipSync} from 'node:zlib';
import {
buildAuthorization,
@@ -65,12 +66,17 @@ async function main() {
await assertHistorySuccessfulUploadCleansAndIsIdempotent();
await assertHistoryResumeReverifiesArchiveAndManifest();
await assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload();
await assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs();
await assertDirectFilesConcurrencyIsBounded();
await assertDirectHistoryPublishesCatalogBeforeCleanup();
await assertDirectHistoryWithoutCandidatesPublishesLatest();
await assertDirectFilesRestoreDownloadsCatalogAndObjects();
}
function readGzipJson(filePath) {
return JSON.parse(gunzipSync(readFileSync(filePath)).toString('utf8'));
}
function createDirectOssHarness() {
const objects = new Map();
const uploadedKeys = [];
@@ -217,6 +223,13 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload(
const first = await runDirectFilesBackup(options);
assertEqual(first.uploadedCount, 2, '首次 files full 应上传全部普通文件。');
assertTrue(!Object.hasOwn(first.catalog, 'dataDir'), '远端 files catalog 不得绑定 staging 主机的绝对 data-dir。');
assertTrue(first.statePath.endsWith('.json.gz'), 'files state 必须使用 gzip 压缩文件。');
const compactState = readGzipJson(first.statePath);
assertEqual(compactState.schemaVersion, 2, 'files state 必须使用去重后的 v2 契约。');
assertTrue(!Object.hasOwn(compactState.baselineCatalog, 'files'), 'baseline ref 不得重复嵌入 files。');
assertTrue(!Object.hasOwn(compactState.latestCatalog, 'files'), 'latest ref 不得重复嵌入 files。');
assertTrue(!existsSync(first.catalogPath), '本地 full catalog 原始 JSON 应在成功后压缩。');
assertTrue(existsSync(`${first.catalogPath}.gz`), '本地应保留压缩后的 latest full catalog 供增量复用。');
const latestObjectKey = 'database-backups/test-db/latest.json';
const latest = JSON.parse(harness.objects.get(latestObjectKey).body.toString('utf8'));
assertEqual(latest.latestFullCatalog.catalogId, first.catalogId, 'latest pointer 必须指向已验真的最新 full catalog。');
@@ -231,11 +244,77 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload(
'相同 catalog 重跑不得重复 PUT 文件或 catalog,但应覆盖验真 latest pointer。',
);
rmSync(`${first.catalogPath}.gz`, {force: false});
writeFileSync(path.join(dataDir, 'control-db'), 'control changed');
writeFileSync(path.join(dataDir, 'new-program.bin'), 'new program');
const incremental = await runDirectFilesBackup(options);
assertEqual(incremental.uploadedCount, 2, '增量 files full 只应上传新增和变化文件。');
assertEqual(incremental.reusedCount, 1, '增量 files full 应复用未变化 snapshot 文件。');
assertEqual(incremental.reusedCount, 1, '本地 full catalog 缓存缺失时仍应通过 OSS HEAD 复用未变化文件。');
}
async function assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs() {
const root = path.join(tmpRoot, 'direct-files-state-migration');
const dataDir = path.join(root, 'stdb');
const workDir = path.join(root, 'work');
mkdirSync(dataDir, {recursive: true});
writeFileSync(path.join(dataDir, 'control-db'), 'control');
const harness = createDirectOssHarness();
const options = {
mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups',
uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn,
};
const first = await runDirectFilesBackup(options);
const compactState = readGzipJson(first.statePath);
const catalog = JSON.parse(gunzipSync(readFileSync(`${first.catalogPath}.gz`)).toString('utf8'));
const legacyCatalogRef = {
...compactState.latestCatalog,
files: catalog.files,
symlinks: catalog.symlinks,
};
const legacyStatePath = first.statePath.slice(0, -3);
writeFileSync(legacyStatePath, `${JSON.stringify({
...compactState,
schemaVersion: 1,
baselineCatalog: legacyCatalogRef,
latestCatalog: legacyCatalogRef,
}, null, 2)}\n`);
rmSync(first.statePath, {force: false});
const migrated = await runDirectFilesBackup(options);
assertTrue(migrated.unchanged, '旧 state 迁移不得改变相同 full catalog 的零上传语义。');
assertTrue(existsSync(migrated.statePath), '旧 state 成功运行后必须生成压缩 state。');
assertTrue(!existsSync(legacyStatePath), '压缩 state 原子落盘后应删除旧未压缩 state。');
const migratedState = readGzipJson(migrated.statePath);
assertEqual(migratedState.schemaVersion, 2, '旧 state 必须迁移到 v2。');
assertTrue(!Object.hasOwn(migratedState.latestCatalog, 'files'), '迁移后 state 不得保留重复 files 清单。');
const latestCatalogPath = `${migrated.catalogPath}.gz`;
const validCatalogBody = readFileSync(latestCatalogPath);
writeFileSync(latestCatalogPath, gzipSync(Buffer.from('{}\n')));
writeFileSync(path.join(dataDir, 'control-db'), 'control changed');
let corruptCatalogFailure = null;
try {
await runDirectFilesBackup(options);
} catch (error) {
corruptCatalogFailure = error;
}
assertIncludes(
corruptCatalogFailure?.message ?? '',
'长度或 SHA 与 state 引用不匹配',
'本地 latest full catalog 损坏时不得作为增量复用缓存。',
);
writeFileSync(latestCatalogPath, validCatalogBody);
writeFileSync(legacyStatePath, `${JSON.stringify({...migratedState, schemaVersion: 1})}\n`);
writeFileSync(migrated.statePath, 'not-a-gzip-state');
let corruptStateFailure = null;
try {
await runDirectFilesBackup(options);
} catch (error) {
corruptStateFailure = error;
}
assertTrue(corruptStateFailure instanceof Error, '压缩 state 损坏时必须失败。');
assertTrue(existsSync(legacyStatePath), '压缩 state 损坏时不得静默回退并删除旧 state。');
}
async function assertDirectFilesConcurrencyIsBounded() {
@@ -290,7 +369,14 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() {
uploadFn: harness.uploadFn,
verifyFn: harness.verifyFn,
};
await runDirectFilesBackup({...common, mode: 'full', uploadManifestFn: harness.uploadManifestFn});
const baseline = await runDirectFilesBackup({...common, mode: 'full', uploadManifestFn: harness.uploadManifestFn});
const legacyResultFile = path.join(fixture.workDir, 'legacy-full-result.json');
const legacyCatalogWithoutSymlinks = {...baseline.catalog};
delete legacyCatalogWithoutSymlinks.symlinks;
writeFileSync(legacyResultFile, `${JSON.stringify({
...baseline,
catalog: legacyCatalogWithoutSymlinks,
}, null, 2)}\n`);
const plan = discoverHistoryPlan({dataDir: fixture.dataDir});
let failure = null;
try {
@@ -330,7 +416,13 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() {
assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `latest pointer 发布失败不得删除: ${candidate.path}`);
}
const success = await runDirectFilesBackup({...common, mode: 'history', uploadManifestFn: harness.uploadManifestFn});
const resultFile = path.join(fixture.workDir, 'history-result.json');
const success = await runDirectFilesBackup({
...common,
mode: 'history',
resultFile,
uploadManifestFn: harness.uploadManifestFn,
});
assertEqual(success.uploadedCount, 0, 'history 文件已在 full CAS baseline 时不应重复上传内容。');
for (const file of success.catalog.files) {
assertTrue(
@@ -340,7 +432,19 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() {
}
assertEqual(success.cleanup?.deletedCount, plan.candidates.length, 'catalog 和 baseline 验真后才应清理全部安全候选。');
const state = JSON.parse(readFileSync(success.statePath, 'utf8'));
const state = readGzipJson(success.statePath);
assertEqual(state.schemaVersion, 2, 'files state 必须迁移为去重后的 v2 契约。');
assertTrue(!Object.hasOwn(state.latestCatalog, 'files'), 'files state latest ref 不得重复嵌入 files。');
assertTrue(state.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), 'files state history ref 不得重复嵌入 files。');
assertTrue(!existsSync(success.catalogPath), '已上传并验真的 history catalog 本地 JSON 应被清理。');
assertTrue(!existsSync(`${success.catalogPath}.gz`), 'history catalog 本地压缩副本也不应保留。');
const diskResult = JSON.parse(readFileSync(resultFile, 'utf8'));
assertTrue(!Object.hasOwn(diskResult.catalog, 'files'), 'files result 文件不得重复写入完整 files 清单。');
assertEqual(diskResult.catalog.fileCount, success.fileCount, '紧凑 result 仍应保留文件计数。');
const compactedLegacyResult = JSON.parse(readFileSync(legacyResultFile, 'utf8'));
assertTrue(!Object.hasOwn(compactedLegacyResult.catalog, 'files'), '旧 result 中重复的 files 清单应在成功运行后压缩。');
assertEqual(compactedLegacyResult.catalog.symlinkCount, 0, '缺少 symlinks 的旧 result 应按零个符号链接兼容迁移。');
assertTrue((success.metadataCleanup?.compactedResultCount ?? 0) >= 1, 'metadata 清理应报告已压缩旧 result。');
const historyCatalogObjectKey = state.historyCatalogs[0].objectKey;
harness.objects.delete(historyCatalogObjectKey);
let brokenHistoryFailure = null;
@@ -425,6 +529,29 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() {
assertTrue(lstatSync(path.join(restoreDir, 'bin', 'current')).isSymbolicLink(), 'files restore 必须重建符号链接。');
assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'files restore 必须保留符号链接目标。');
rmSync(restoreDir, {recursive: true, force: true});
const legacyRestoreStatePath = path.join(workDir, 'legacy-restore-state.json');
writeFileSync(legacyRestoreStatePath, `${JSON.stringify({
...readGzipJson(latestFull.statePath),
schemaVersion: 1,
})}\n`);
const legacyRestored = await restoreDirectFilesBackup({
statePath: legacyRestoreStatePath,
restoreDir,
database: 'test-db',
bucket: 'backup-bucket',
uploadOptions: {},
downloadBufferFn: async ({objectKey}) => Buffer.from(harness.objects.get(objectKey)?.body ?? ''),
downloadFileFn: async ({objectKey, destinationPath}) => {
const object = harness.objects.get(objectKey);
if (!object) {
throw new Error(`missing ${objectKey}`);
}
writeFileSync(destinationPath, object.body);
},
});
assertEqual(legacyRestored.downloadedCount, 1, 'files restore 必须继续兼容 v1 JSON state。');
rmSync(restoreDir, {recursive: true, force: true});
const downloadBufferFn = async ({objectKey}) => {
const object = harness.objects.get(objectKey);
+367
View File
@@ -0,0 +1,367 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDir, '..');
const manifestPath = 'server-rs/Cargo.toml';
const targetName = 'module_runtime';
const retiredSymbolSignatures = [
'CreationEntryConfigSnapshot',
'RuntimeBrowseHistorySnapshot',
'RuntimeProfilePlayedWorldSnapshot',
'RuntimeProfileSaveArchiveSnapshot',
'build_runtime_snapshot_record',
'prepare_runtime_browse_history_entries',
'resolve_runtime_profile_save_archive_meta',
];
const retiredStringSignatures = [
'/creation-type-references/puzzle.webp',
'customWorldProfile',
'storyEngineMemory',
];
const requiredAbiSignatures = [
'RuntimeBrowseHistoryThemeMode',
'RuntimeProfileWalletLedgerSourceType',
'RuntimeSettingSnapshot',
];
function cargoDiagnostics(stdout) {
const diagnostics = [];
for (const line of stdout.split(/\r?\n/u)) {
if (!line.trim()) {
continue;
}
try {
const message = JSON.parse(line);
if (message.reason === 'compiler-message' && message.message?.rendered) {
diagnostics.push(message.message.rendered.trimEnd());
}
} catch {
// Cargo may emit a non-JSON line before failing to start rustc.
}
}
return diagnostics;
}
function failBuild(result) {
console.error(
'module-runtime 编译产物门禁失败:无法完成 module-runtime 构建。',
);
for (const diagnostic of cargoDiagnostics(result.stdout ?? '')) {
console.error(diagnostic);
}
if (result.stderr) {
console.error(result.stderr.trimEnd());
}
if (result.error) {
console.error(`- 无法执行 Cargo${result.error.message}`);
}
process.exit(result.status || 1);
}
function collectArtifactPaths(stdout, artifactTargetName = targetName) {
const paths = new Set();
for (const line of stdout.split(/\r?\n/u)) {
if (!line.trim()) {
continue;
}
let message;
try {
message = JSON.parse(line);
} catch {
continue;
}
if (
message.reason !== 'compiler-artifact' ||
message.target?.name !== artifactTargetName ||
!message.target?.kind?.includes('lib')
) {
continue;
}
for (const fileName of message.filenames ?? []) {
if (!fileName.endsWith('.rlib') && !fileName.endsWith('.rmeta')) {
continue;
}
const absolutePath = isAbsolute(fileName)
? fileName
: join(repoRoot, fileName);
if (existsSync(absolutePath)) {
paths.add(absolutePath);
}
}
}
return [...paths];
}
function latestRlib(paths) {
const rlibs = paths.filter((path) => path.endsWith('.rlib'));
return rlibs.sort(
(left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs,
)[0];
}
function parseArchiveObjectMembers(artifact) {
const archiveMagic = artifact.subarray(0, 8).toString('ascii');
if (archiveMagic !== '!<arch>\n') {
throw new Error('产物不是可识别的 Unix rlib 归档');
}
const objectMembers = [];
let longNameTable = null;
let offset = 8;
while (offset + 60 <= artifact.length) {
const header = artifact.subarray(offset, offset + 60);
if (header.subarray(58, 60).toString('ascii') !== '`\n') {
throw new Error(`rlib 成员头损坏,偏移量 ${offset}`);
}
const rawName = header.subarray(0, 16).toString('ascii').trim();
const sizeText = header.subarray(48, 58).toString('ascii').trim();
const size = Number.parseInt(sizeText, 10);
if (!Number.isSafeInteger(size) || size < 0) {
throw new Error(`rlib 成员大小无效:${sizeText || '<empty>'}`);
}
let contentStart = offset + 60;
const contentEnd = contentStart + size;
if (contentEnd > artifact.length) {
throw new Error(`rlib 成员越界,偏移量 ${offset}`);
}
let memberName = rawName.replace(/\/$/u, '');
if (rawName === '//') {
longNameTable = artifact.subarray(contentStart, contentEnd);
} else if (/^\/\d+$/u.test(rawName) && longNameTable) {
const nameOffset = Number.parseInt(rawName.slice(1), 10);
const nameEnd = longNameTable.indexOf(0x0a, nameOffset);
const resolvedEnd = nameEnd >= 0 ? nameEnd : longNameTable.length;
memberName = longNameTable
.subarray(nameOffset, resolvedEnd)
.toString('utf8')
.replace(/\/$/u, '');
} else if (rawName.startsWith('#1/')) {
const nameLength = Number.parseInt(rawName.slice(3), 10);
if (!Number.isSafeInteger(nameLength) || nameLength > size) {
throw new Error(`rlib BSD 扩展成员名长度无效:${rawName}`);
}
memberName = artifact
.subarray(contentStart, contentStart + nameLength)
.toString('utf8');
contentStart += nameLength;
}
if (memberName.endsWith('.o')) {
objectMembers.push(artifact.subarray(contentStart, contentEnd));
}
offset = contentEnd + (size % 2);
}
if (objectMembers.length === 0) {
throw new Error('rlib 中没有可扫描的 Rust object 成员');
}
return objectMembers;
}
console.log('构建 module-runtime 并检查退役业务签名...');
const cargo = process.env.CARGO || 'cargo';
const buildResult = spawnSync(
cargo,
[
'build',
'--manifest-path',
manifestPath,
'--package',
'module-runtime',
'--all-features',
'--message-format=json-render-diagnostics',
'--color=never',
],
{
cwd: repoRoot,
encoding: 'utf8',
env: process.env,
maxBuffer: 256 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
if (buildResult.error || buildResult.status !== 0) {
failBuild(buildResult);
}
const artifactPaths = collectArtifactPaths(buildResult.stdout);
const artifactPath = latestRlib(artifactPaths);
if (!artifactPath) {
console.error(
'module-runtime 编译产物门禁失败:Cargo JSON 中没有 module_runtime 的 rlib。',
);
console.error(
'- rmeta 会保留被 cfg 禁用的源码 token,无法作为退役业务负向扫描依据;请确认执行的是 cargo build 而不是 cargo check。',
);
process.exit(1);
}
const artifact = readFileSync(artifactPath);
let objectMembers;
try {
objectMembers = parseArchiveObjectMembers(artifact);
} catch (error) {
console.error(`module-runtime 编译产物门禁失败:${artifactPath}`);
console.error(`- 无法读取 rlib object 成员:${error.message}`);
process.exit(1);
}
const retiredSymbolMatches = retiredSymbolSignatures.filter((signature) =>
objectMembers.some((member) => member.includes(Buffer.from(signature))),
);
const retiredStringMatches = retiredStringSignatures.filter((signature) =>
artifact.includes(Buffer.from(signature)),
);
const missingAbiSignatures = requiredAbiSignatures.filter(
(signature) =>
!objectMembers.some((member) => member.includes(Buffer.from(signature))),
);
if (
retiredSymbolMatches.length > 0 ||
retiredStringMatches.length > 0 ||
missingAbiSignatures.length > 0
) {
console.error(`module-runtime 编译产物门禁失败:${artifactPath}`);
for (const signature of retiredSymbolMatches) {
console.error(`- 退役业务符号仍存在:${signature}`);
}
for (const signature of retiredStringMatches) {
console.error(`- 退役业务字符串仍存在:${signature}`);
}
for (const signature of missingAbiSignatures) {
console.error(`- 必须保留的 ABI 签名缺失:${signature}`);
}
process.exit(1);
}
console.log(
`module-runtime 编译产物门禁通过:${retiredSymbolSignatures.length} 个退役符号与 ${retiredStringSignatures.length} 个退役字符串均不存在,${requiredAbiSignatures.length} 个兼容 ABI 签名均存在。`,
);
console.log(`已检查产物:${artifactPath}`);
function checkPlatformRetirementArtifact({
packageName,
artifactTargetName,
symbolSignatures,
stringSignatures,
}) {
const result = spawnSync(
cargo,
[
'build',
'--manifest-path',
manifestPath,
'--package',
packageName,
'--all-features',
'--message-format=json-render-diagnostics',
'--color=never',
],
{
cwd: repoRoot,
encoding: 'utf8',
env: process.env,
maxBuffer: 256 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
if (result.error || result.status !== 0) {
console.error(`${packageName} 编译产物门禁失败:无法完成构建。`);
for (const diagnostic of cargoDiagnostics(result.stdout ?? '')) {
console.error(diagnostic);
}
if (result.stderr) {
console.error(result.stderr.trimEnd());
}
process.exit(result.status || 1);
}
const paths = collectArtifactPaths(result.stdout, artifactTargetName);
const path = latestRlib(paths);
if (!path) {
console.error(`${packageName} 编译产物门禁失败:Cargo JSON 中没有 rlib。`);
process.exit(1);
}
const bytes = readFileSync(path);
let members;
try {
members = parseArchiveObjectMembers(bytes);
} catch (error) {
console.error(`${packageName} 编译产物门禁失败:${error.message}`);
process.exit(1);
}
const symbolMatches = symbolSignatures.filter((signature) =>
members.some((member) => member.includes(Buffer.from(signature))),
);
const stringMatches = stringSignatures.filter((signature) =>
bytes.includes(Buffer.from(signature)),
);
if (symbolMatches.length > 0 || stringMatches.length > 0) {
console.error(`${packageName} 编译产物门禁失败:${path}`);
for (const signature of symbolMatches) {
console.error(`- 退役业务符号仍存在:${signature}`);
}
for (const signature of stringMatches) {
console.error(`- 退役业务字符串仍存在:${signature}`);
}
process.exit(1);
}
console.log(
`${packageName} 编译产物门禁通过:${symbolSignatures.length} 个退役符号与 ${stringSignatures.length} 个退役字符串均不存在。`,
);
console.log(`已检查产物:${path}`);
}
checkPlatformRetirementArtifact({
packageName: 'platform-auth',
artifactTargetName: 'platform_auth',
symbolSignatures: [
'RuntimeGuestTokenClaims',
'sign_runtime_guest_token',
'verify_runtime_guest_token',
],
stringSignatures: ['runtime:public-play', 'runtime_guest'],
});
checkPlatformRetirementArtifact({
packageName: 'platform-wechat',
artifactTargetName: 'platform_wechat',
symbolSignatures: ['WechatSubscribeMessageRequest', 'send_subscribe_message'],
stringSignatures: [
'/cgi-bin/message/subscribe/send',
'subscribeMessage.send',
],
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -299,7 +299,7 @@ function runRealpathStaticChecks(content) {
}
const healthzHeader = `location = ${REALPATH_HEALTHZ_PATH}`;
const apiHeader = 'location = /api/creation-entry/config';
const apiHeader = 'location = /api/assets/history';
const websocketHeader = 'location ~ ^/v1/database/[^/]+/subscribe$';
const identityHeader = 'location ^~ /v1/identity';
const assetHeader = 'location = /assets/app.js';
+3 -12
View File
@@ -2,9 +2,9 @@
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 APP_PAGE_ROUTES_PATH = 'src/routing/activeAppPageRoutes.ts';
const APP_ROUTES_PATH = 'src/routing/activeAppRoutes.tsx';
const COMPATIBILITY_ROUTES = [];
const NGINX_PATHS = [
'deploy/nginx/genarrative.conf',
'deploy/nginx/genarrative-dev-http.conf',
@@ -48,21 +48,12 @@ function collectExpectedMainSpaRoutes() {
/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],
@@ -81,14 +81,14 @@ function assertParitySucceeds() {
nginxLine(
'rid-api',
'GET',
'/__genarrative_pingora_canary/api/creation-entry/config',
'/__genarrative_pingora_canary/api/assets/history',
200,
),
], [
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200, {
route: 'shadow_probe',
}),
pingoraLine('rid-api', 'GET', '/api/creation-entry/config', 200, {
pingoraLine('rid-api', 'GET', '/api/assets/history', 200, {
route: 'api_proxy',
proxyTarget: 'api-server',
}),
@@ -98,7 +98,7 @@ function assertParitySucceeds() {
'--path',
'/__genarrative_pingora_canary/healthz',
'--path',
'/__genarrative_pingora_canary/api/creation-entry/config',
'/__genarrative_pingora_canary/api/assets/history',
'--json',
]);
assertStatus(result, 0, '日志对照完整时必须通过。');
@@ -119,13 +119,13 @@ function assertRealpathParitySucceeds() {
'/__genarrative_pingora_realpath_canary/healthz',
200,
),
nginxLine('rid-real-api', 'GET', '/api/creation-entry/config', 200),
nginxLine('rid-real-api', 'GET', '/api/assets/history', 200),
nginxLine('rid-real-asset', 'GET', '/assets/app.js', 200),
], [
pingoraLine('rid-real-health', 'GET', '/__genarrative_pingora/healthz', 200, {
route: 'shadow_probe',
}),
pingoraLine('rid-real-api', 'GET', '/api/creation-entry/config', 200, {
pingoraLine('rid-real-api', 'GET', '/api/assets/history', 200, {
route: 'api_proxy',
proxyTarget: 'api-server',
}),
@@ -139,7 +139,7 @@ function assertRealpathParitySucceeds() {
'--path',
'/__genarrative_pingora_realpath_canary/healthz',
'--path',
'/api/creation-entry/config',
'/api/assets/history',
'--path',
'/assets/app.js',
'--json',
@@ -193,7 +193,7 @@ function assertRequiredPathFails() {
]);
const result = runParity(fixture, [
'--path',
'/__genarrative_pingora_canary/api/creation-entry/config',
'/__genarrative_pingora_canary/api/assets/history',
]);
assertStatus(result, 1, '必需路径未出现在 Nginx canary 日志时必须失败。');
assertIncludes(
@@ -390,7 +390,7 @@ function isCanaryRecord(record) {
if (config.mode === 'realpath') {
return (
record.path === REALPATH_HEALTHZ_PATH ||
record.path === '/api/creation-entry/config' ||
record.path === '/api/assets/history' ||
record.path.startsWith('/v1/database/') ||
record.path.startsWith('/v1/identity') ||
record.path === '/assets/app.js' ||
+7 -13
View File
@@ -141,12 +141,6 @@ async function main() {
GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_MAX_CONCURRENT: '16',
GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_RATE_PER_SECOND: '100',
GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_BURST: '100',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_LIST_MAX_CONCURRENT: '16',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_LIST_RATE_PER_SECOND: '100',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_LIST_BURST: '100',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_DETAIL_MAX_CONCURRENT: '16',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_DETAIL_RATE_PER_SECOND: '100',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_DETAIL_BURST: '100',
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_MAX_CONCURRENT: '16',
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_RATE_PER_SECOND: '100',
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_BURST: '100',
@@ -252,19 +246,19 @@ async function main() {
await expectAccessLogContains(nginxAccessLogFile, [
'/__genarrative_pingora_canary/healthz',
'/__genarrative_pingora_canary/api/creation-entry/config',
'/__genarrative_pingora_canary/api/assets/history',
'/__genarrative_pingora_canary/v1/identity',
'/__genarrative_pingora_canary/assets/app.js',
]);
await expectAccessLogContains(realpathNginxAccessLogFile, [
'/__genarrative_pingora_realpath_canary/healthz',
'/api/creation-entry/config',
'/api/assets/history',
'/v1/identity',
'/assets/app.js',
]);
await expectAccessLogContains(accessLogFile, [
'path=/__genarrative_pingora/healthz',
'path=/api/creation-entry/config',
'path=/api/assets/history',
'path=/v1/identity',
'path=/assets/app.js',
]);
@@ -280,7 +274,7 @@ async function main() {
'--path',
'/__genarrative_pingora_canary/healthz',
'--path',
'/__genarrative_pingora_canary/api/creation-entry/config',
'/__genarrative_pingora_canary/api/assets/history',
'--path',
'/__genarrative_pingora_canary/v1/identity',
'--path',
@@ -298,7 +292,7 @@ async function main() {
'--path',
'/__genarrative_pingora_realpath_canary/healthz',
'--path',
'/api/creation-entry/config',
'/api/assets/history',
'--path',
'/v1/identity',
'--path',
@@ -308,7 +302,7 @@ async function main() {
if (!realUpstreams) {
ensure(
api.state.requests.some(
(request) => request.url === '/api/creation-entry/config',
(request) => request.url === '/api/assets/history',
),
'Docker Nginx canary 未把 API 代表路径交给 mock api-server',
);
@@ -318,7 +312,7 @@ async function main() {
);
ensure(
api.state.requests.filter(
(request) => request.url === '/api/creation-entry/config',
(request) => request.url === '/api/assets/history',
).length >= 2,
'Docker Nginx realpath canary 未把真实 API 代表路径交给 mock api-server',
);
+1 -1
View File
@@ -38,7 +38,7 @@ function main() {
'--base-url',
'http://127.0.0.1',
'--path',
'/api/creation-entry/config\nX-Injected: yes',
'/api/assets/history\nX-Injected: yes',
]);
assertRejectsControlCharacter('--timeout-ms', [
'--base-url',
+2 -2
View File
@@ -235,8 +235,8 @@ async function main() {
bodyReason: 'body 应包含 gateway=pingora-shadow',
},
{
name: 'api-config',
path: '/api/creation-entry/config',
name: 'api-history',
path: '/api/assets/history',
expectedStatuses: [200, 401, 403, 503],
},
{
@@ -1779,10 +1779,10 @@ function prepareFixture(name, options = {}) {
' actualStatusCode: 200,',
' }],',
' missing: status === "OK" ? [] : [{',
' name: "https-api-config",',
' name: "https-api-history",',
' requestId: "direct-live-api",',
' expectedMethod: "GET",',
' expectedPath: "/api/creation-entry/config",',
' expectedPath: "/api/assets/history",',
' expectedStatusCode: 200,',
' }],',
' mismatches: [],',
+4 -4
View File
@@ -354,8 +354,8 @@ async function main() {
assertHeader: assertPingoraGatewayHeader,
},
{
name: 'https-api-config',
url: joinUrl(config.httpsBaseUrl, '/api/creation-entry/config'),
name: 'https-api-history',
url: joinUrl(config.httpsBaseUrl, '/api/assets/history'),
expectedStatuses: [200, 401, 403, 503],
assertHeader: assertPingoraGatewayHeader,
},
@@ -405,14 +405,14 @@ async function main() {
},
{
name: 'http-api-redirect',
url: joinUrl(config.httpBaseUrl, '/api/creation-entry/config?direct=1'),
url: joinUrl(config.httpBaseUrl, '/api/assets/history?direct=1'),
expectedStatus: 301,
assertHeader: assertPingoraGatewayHeader,
assertLocation: (location) =>
location ===
directHttpsLocation(
config.httpsBaseUrl,
'/api/creation-entry/config?direct=1',
'/api/assets/history?direct=1',
),
},
{
+31 -15
View File
@@ -180,12 +180,6 @@ async function main() {
GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_MAX_CONCURRENT: '1',
GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_RATE_PER_SECOND: '0',
GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_BURST: '0',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_LIST_MAX_CONCURRENT: '16',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_LIST_RATE_PER_SECOND: '100',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_LIST_BURST: '100',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_DETAIL_MAX_CONCURRENT: '16',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_DETAIL_RATE_PER_SECOND: '100',
GENARRATIVE_PINGORA_GATEWAY_GALLERY_DETAIL_BURST: '100',
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_MAX_CONCURRENT: '16',
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_RATE_PER_SECOND: '100',
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_BURST: '100',
@@ -573,7 +567,18 @@ async function runSmokeCases(
);
await expectHttp(
baseUrl,
'/creation/puzzle/result',
'/creation',
200,
'site-shell',
'新创作主页深链回退 index.html',
{
validate: (response) =>
response.headers['cache-control'] === 'no-cache',
},
);
await expectHttp(
baseUrl,
'/project',
200,
'site-shell',
'主站 allowlist 深链回退 index.html',
@@ -584,7 +589,18 @@ async function runSmokeCases(
);
await expectHttp(
baseUrl,
'/CREATION/PUZZLE/RESULT/',
'/profile',
200,
'site-shell',
'个人页深链回退 index.html',
{
validate: (response) =>
response.headers['cache-control'] === 'no-cache',
},
);
await expectHttp(
baseUrl,
'/PROJECT/',
200,
'site-shell',
'主站 allowlist 允许大小写差异和尾部斜杠',
@@ -879,7 +895,7 @@ async function runSmokeCases(
);
await expectHttp(
redirectBaseUrl,
'/api/creation-entry/config?from=smoke',
'/api/assets/history?kind=character_visual&from=smoke',
301,
'',
'HTTP 入口 301 到 HTTPS',
@@ -887,7 +903,7 @@ async function runSmokeCases(
headers: { Host: 'example.test' },
validate: (response) =>
response.headers.location ===
'https://example.test/api/creation-entry/config?from=smoke',
'https://example.test/api/assets/history?kind=character_visual&from=smoke',
},
);
await expectHttp(
@@ -933,7 +949,7 @@ async function runSmokeCases(
const apiResponse = await expectHttp(
baseUrl,
'/api/creation-entry/config',
'/api/assets/history',
200,
'"upstream":"api"',
'通用 API 转发',
@@ -1171,7 +1187,7 @@ async function runSmokeCases(
);
for (const [path, bodyNeedle, label] of [
['/', 'runtime-maintenance', '公网主站页面'],
['/api/creation-entry/config', 'MAINTENANCE', '公网普通 API'],
['/api/assets/history', 'MAINTENANCE', '公网普通 API'],
['/v1/identity', 'runtime-maintenance', '公网 SpacetimeDB 路由'],
['/admin/settings', 'runtime-maintenance', '公网后台页面'],
['/admin/assets/admin.js', 'runtime-maintenance', '公网后台静态资源'],
@@ -1207,7 +1223,7 @@ async function runSmokeCases(
);
await expectHttp(
baseUrl,
'/api/creation-entry/config',
'/api/assets/history',
200,
'"upstream":"api"',
'维护模式允许内网普通 API',
@@ -1247,7 +1263,7 @@ async function runSmokeCases(
);
await expectAccessLogContains(accessLogFile, [
'status=503',
'path=/api/creation-entry/config',
'path=/api/assets/history',
]);
}
@@ -1259,7 +1275,7 @@ async function expectAccessLog(accessLogFile) {
return (
content.includes('request_id=smoke-request-id') &&
content.includes('method=GET') &&
content.includes('path=/api/creation-entry/config') &&
content.includes('path=/api/assets/history') &&
content.includes('status=200') &&
content.includes('proxy_target=Api') &&
content.includes('protection_class=api') &&
@@ -2247,7 +2247,7 @@ function assertRequireLiveForcesHost() {
);
assertIncludes(
parity.args,
'/__genarrative_pingora_canary/api/creation-entry/config',
'/__genarrative_pingora_canary/api/assets/history',
'--require-live access log 对账必须覆盖代表性 API canary 路径。',
);
}
@@ -2299,7 +2299,7 @@ function assertRequireRealpathLiveForcesHostAndRealpathParity() {
);
assertIncludes(
parity.args,
'/api/creation-entry/config',
'/api/assets/history',
'--require-realpath-live access log 对账必须覆盖真实 API 路径。',
);
assertIncludes(
+2 -2
View File
@@ -2026,7 +2026,7 @@ function appendTargetLiveSteps(steps, config, scriptPath) {
'--path',
'/__genarrative_pingora_canary/healthz',
'--path',
'/__genarrative_pingora_canary/api/creation-entry/config',
'/__genarrative_pingora_canary/api/assets/history',
],
cwd: releaseRoot,
});
@@ -2065,7 +2065,7 @@ function appendTargetRealpathLiveSteps(steps, config, scriptPath) {
'--path',
'/__genarrative_pingora_realpath_canary/healthz',
'--path',
'/api/creation-entry/config',
'/api/assets/history',
'--path',
'/v1/identity',
'--path',
+2 -7
View File
@@ -23,8 +23,6 @@ const VALID_STATIC_ROOTS = new Set(['web', 'acme']);
const VALID_STATIC_MODES = new Set(['exact', 'spa_fallback']);
const VALID_PROTECTION_CLASSES = new Set([
'admin_api',
'gallery_list',
'gallery_detail',
'api',
'spacetime',
]);
@@ -36,10 +34,6 @@ const REQUIRED_ROUTE_IDS = [
'admin_assets',
'admin_spa_fallback',
'web_assets',
'puzzle_gallery_list',
'custom_world_gallery_list',
'puzzle_gallery_detail',
'custom_world_gallery_detail',
'generic_api_proxy',
'spacetime_subscribe',
'spacetime_identity',
@@ -48,6 +42,7 @@ const REQUIRED_ROUTE_IDS = [
'readyz_forbidden',
'generated_assets_forbidden',
'web_spa_fallback',
'profile_spa_fallback',
'web_spa_case_trailing_slash',
'web_unknown_path_exact',
'creation_unknown_path_exact',
@@ -240,7 +235,7 @@ function validateRustTestUsesMatrix() {
function validateRustMainSpaRoutes() {
const routeBlock = pingoraGatewaySource.match(
/const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\n\];/u,
/const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u,
);
if (!routeBlock) {
fail('Pingora Rust 缺少 MAIN_SPA_PATHS allowlist。');
File diff suppressed because it is too large Load Diff
+36
View File
@@ -258,6 +258,42 @@ function assertApiReleaseContainsPingoraDirectDependencies() {
),
'API release 必须包含外部生成 worker controller systemd 单元。',
);
assertFileExists(
path.join(
releaseDir,
'deploy/systemd/genarrative-bgfilter-worker.service',
),
'API release 必须包含唯一 BgFilter worker systemd 单元。',
);
assertFileExists(
path.join(releaseDir, 'deploy/env/bgfilter-worker.env.example'),
'API release 必须包含 BgFilter worker env 示例。',
);
const bgfilterUnit = readFileSync(
path.join(
releaseDir,
'deploy/systemd/genarrative-bgfilter-worker.service',
),
'utf8',
);
const sharedEnvIndex = bgfilterUnit.indexOf(
'EnvironmentFile=/etc/genarrative/api-server.env',
);
const dedicatedEnvIndex = bgfilterUnit.indexOf(
'EnvironmentFile=/etc/genarrative/bgfilter-worker.env',
);
if (
sharedEnvIndex < 0 ||
dedicatedEnvIndex < 0 ||
sharedEnvIndex > dedicatedEnvIndex
) {
failures.push('API release 的 BgFilter unit 必须按共享 env → 专属 env 加载。');
}
assertIncludes(
bgfilterUnit,
'TimeoutStopSec=900',
'API release 的 BgFilter unit 必须给取得 permit 后的公式化 callBudget 留足优雅排空时间。',
);
assertFileExists(
path.join(releaseDir, 'deploy/pingora/pingora-gateway.env.example'),
'API release 必须包含 Pingora env 示例。',
+16 -1
View File
@@ -54,6 +54,11 @@ function assertPublicBaseUrlDefaultsToGatewayEntry() {
"process.env.GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL ||\n 'http://127.0.0.1'",
'publicBaseUrl 默认必须指向本机网关入口,不能回落到 API 直连端口。',
);
assertIncludes(
script,
"process.env.GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL ||\n 'http://127.0.0.1:8083'",
'BgFilter worker 巡检默认必须指向唯一实例的 loopback 端口。',
);
if (
script.includes(
'process.env.GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL ||\n process.env.GENARRATIVE_HEALTH_PATROL_API_BASE_URL',
@@ -85,6 +90,14 @@ async function assertNginxModeChecksNginxService() {
'systemctl is-active nginx.service',
'nginx gateway mode 必须检查 nginx.service。',
);
assertIncludes(
commandsLog,
'systemctl is-active genarrative-bgfilter-worker.service',
'生产巡检必须检查唯一 BgFilter worker service。',
);
if (!payload.checks.some((check) => check.name === 'bgfilter:/readyz')) {
failures.push('生产巡检必须探测 BgFilter worker /readyz。');
}
if (commandsLog.includes('genarrative-pingora-gateway.service')) {
failures.push(
'nginx gateway mode 不应要求 Pingora gateway service active。',
@@ -131,7 +144,7 @@ async function assertPingoraDirectModeChecksPingoraServiceAndPublicHost() {
);
assertIncludes(
requestsLog,
'host=genarrative.example path=/api/creation-entry/config',
'host=genarrative.example path=/',
'public probe 必须带正式域名 Host header。',
);
}
@@ -369,6 +382,8 @@ async function runPatrol(fixture, args) {
'scripts/ops/production-health-patrol.mjs',
'--api-base-url',
fixture.baseUrl,
'--bgfilter-base-url',
fixture.baseUrl,
'--spacetime-base-url',
fixture.baseUrl,
'--public-base-url',
+177 -7
View File
@@ -233,6 +233,12 @@ const checks = [
reason:
'API readiness 单次请求必须有超时,避免端口已建立但服务尚未响应时绕过重试上限无限挂起。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes:
'on_exit() {\n local exit_code=$?\n cleanup_rendered_systemd_unit',
reason: 'API deploy 被中断时必须清理尚未安装完成的临时 systemd unit。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes:
@@ -244,6 +250,54 @@ const checks = [
includes: 'maintenance_deploy_args+=(--keep-maintenance-mode)',
reason: 'API Deploy Job 必须把保持维护参数传给发布产物内的部署脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes:
"string(name: 'CONTROLLER_ENV_FILE', defaultValue: '/etc/genarrative/external-generation-controller.env'",
reason: 'API Deploy Job 必须暴露外部生成 controller env 路径。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes:
"string(name: 'BGFILTER_WORKER_ENV_FILE', defaultValue: '/etc/genarrative/bgfilter-worker.env'",
reason: 'API Deploy Job 必须暴露 BgFilter worker env 路径。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes:
'--controller-env-file "${CONTROLLER_ENV_FILE:-/etc/genarrative/external-generation-controller.env}"',
reason: 'API Deploy Job 必须把 controller env 路径传给发布脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes:
'--bgfilter-worker-env-file "${BGFILTER_WORKER_ENV_FILE:-/etc/genarrative/bgfilter-worker.env}"',
reason: 'API Deploy Job 必须把 BgFilter worker env 路径传给发布脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
"string(name: 'CONTROLLER_ENV_FILE', defaultValue: '/etc/genarrative/external-generation-controller.env'",
reason: 'Full Job 必须暴露外部生成 controller env 路径。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
"string(name: 'BGFILTER_WORKER_ENV_FILE', defaultValue: '/etc/genarrative/bgfilter-worker.env'",
reason: 'Full Job 必须暴露 BgFilter worker env 路径。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
"string(name: 'CONTROLLER_ENV_FILE', value: params.CONTROLLER_ENV_FILE ?: '/etc/genarrative/external-generation-controller.env')",
reason: 'Full Job 必须把 controller env 路径传给 API Deploy Job。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
"string(name: 'BGFILTER_WORKER_ENV_FILE', value: params.BGFILTER_WORKER_ENV_FILE ?: '/etc/genarrative/bgfilter-worker.env')",
reason: 'Full Job 必须把 BgFilter worker env 路径传给 API Deploy Job。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'ensure_runtime_bootstrap_secret_file_env',
@@ -521,6 +575,46 @@ const checks = [
includes: 'completed_before_micros: encodeSpacetimeCliOption(',
reason: '历史 payload 截止时间必须编码为 CLI SATS Option,确保事故时间过滤可调用。',
},
{
file: 'scripts/spacetime-migrate-editor-canvas-layout.mjs',
includes: 'dry_run: !options.apply',
reason: '图片画布存量迁移必须默认 dry-run,只有显式 --apply 才写入。',
},
{
file: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
includes: 'buildProcedureInput(canvas, updatedAtMicros, !options.apply)',
reason: '图片画布资源修复必须默认 dry-run,只有显式 --apply 才写入。',
},
{
file: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
includes: '--confirm-plan-sha256',
reason: '图片画布资源修复 apply 必须绑定同一份 dry-run plan 摘要。',
},
{
file: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
includes: '(metadata.mode & 0o777) !== 0o600',
reason: '包含真实资源 ID 的修复 plan 必须使用仓库外 0600 文件。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs',
reason: 'Stdb Build 必须归档图片画布存量迁移脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs',
reason: 'Stdb Publish 必须从同一上游制品复制图片画布存量迁移脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
reason: 'Stdb Build 必须归档图片画布资源修复脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
reason: 'Stdb Publish 必须从同一上游制品复制图片画布资源修复脚本。',
},
{
file: 'scripts/deploy-rust-remote.sh',
excludes:
@@ -1546,7 +1640,7 @@ const checks = [
},
{
file: 'deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf',
includes: 'location = /api/creation-entry/config',
includes: 'location = /api/assets/history',
reason:
'Pingora 真实路径 canary 必须覆盖代表性 API 真实路径。',
},
@@ -1607,6 +1701,63 @@ const checks = [
includes: 'genarrative-external-generation-worker@1.service',
reason: 'Server-Provision 必须启用外部生成保底 worker 实例。',
},
{
file: 'deploy/systemd/genarrative-bgfilter-worker.service',
includes: 'TimeoutStopSec=900',
reason:
'BgFilter worker 必须给取得 permit 后的公式化 callBudget 留足优雅排空时间,不能沿用 systemd 默认停止窗口。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'validate_no_bgfilter_internal_token_plaintext',
reason:
'Server-Provision 必须拒绝 API 或 BgFilter worker env 保存内部 Token 明文。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes:
'for env_file in "${API_ENV_FILE}" "${WORKER_ENV_FILE}" "${BGFILTER_WORKER_ENV_FILE}"; do',
reason:
'Server-Provision 必须同时拒绝 external-generation-worker.env 保存 BgFilter 内部 Token 明文。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes:
'validate_bgfilter_env_file_alignment "${WORKER_ENV_FILE}" "外部生成 worker env" "false"',
reason:
'Server-Provision 启动外部生成 worker 前必须拒绝 BgFilter URL、Token 文件、timeout 与 OSS 位置漂移。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'validate_bgfilter_loopback_endpoint_alignment',
reason:
'Server-Provision 启动 BgFilter worker 前必须确认父 base URL 与子 listener 指向同一 loopback endpoint。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'BgFilter 内部 Token 文件必须为不含空白字符的单段值',
reason:
'Server-Provision 必须拒绝纯空白、含内部空白或包含多个非空行的 BgFilter 内部 Token 文件。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'current_value="$(read_effective_env_value "${file}" "${key}")"',
reason:
'Server-Provision 迁移历史默认值时必须读取最后一次有效赋值,不能覆盖后写的自定义运行态值。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes:
'ensure_env_value_migrates_old_default "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300" "120"',
reason:
'Server-Provision 必须把 BgFilter 熔断 cooldown 历史模板默认 300 定向迁移为 120,并保留其它显式定制值。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: "root:genarrative:440",
reason:
'Server-Provision 必须复核 BgFilter 内部 Token 文件的 owner、group 与 0440 权限。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: 'ensure_default_worker_service',
@@ -3869,7 +4020,7 @@ const checks = [
},
{
file: 'scripts/check-pingora-canary-docker.mjs',
includes: '/__genarrative_pingora_canary/api/creation-entry/config',
includes: '/__genarrative_pingora_canary/api/assets/history',
reason:
'Pingora Docker canary access log 对账必须覆盖代表性 API canary 路径。',
},
@@ -6446,11 +6597,6 @@ const checks = [
includes: 'GENARRATIVE_PINGORA_GATEWAY_API_MAX_CONCURRENT',
reason: 'Pingora 网关配置示例必须保留通用 API 并发保护参数。',
},
{
file: 'deploy/pingora/pingora-gateway.env.example',
includes: 'GENARRATIVE_PINGORA_GATEWAY_GALLERY_LIST_RATE_PER_SECOND',
reason: 'Pingora 网关配置示例必须保留公开列表 RPS 保护参数。',
},
{
file: 'deploy/pingora/pingora-gateway.env.example',
includes: 'GENARRATIVE_PINGORA_GATEWAY_ACCESS_LOG_FILE',
@@ -7092,6 +7238,30 @@ if ((fullPipelineMaintenanceHoldCalls?.length ?? 0) !== 2) {
);
}
const exitMaintenanceStageOffset = fullPipelineContent.indexOf(
"stage('Exit Maintenance')",
);
const fullPipelinePostOffset = fullPipelineContent.indexOf(
'\n post {',
exitMaintenanceStageOffset,
);
const exitMaintenanceStageContent =
exitMaintenanceStageOffset >= 0 && fullPipelinePostOffset > exitMaintenanceStageOffset
? fullPipelineContent.slice(exitMaintenanceStageOffset, fullPipelinePostOffset)
: '';
if (
!exitMaintenanceStageContent.includes('agent none') ||
!exitMaintenanceStageContent.includes('node(deployLabel)') ||
exitMaintenanceStageContent.includes("$class: 'GitSCM'") ||
exitMaintenanceStageContent.includes('checkout scm') ||
exitMaintenanceStageContent.includes('sshUserPrivateKey(')
) {
failed = true;
console.error(
'[check:production-ops] Full Build 的 Exit Maintenance 必须使用 agent none + 显式 node 执行 current release 脚本,不得在目标机 checkout Git 或挂载 Git SSH 凭据。',
);
}
for (const file of nodeEnvFileCommandFiles) {
const content = readFileSync(file, 'utf8');
const commandText = content.replace(/\\\r?\n\s*/g, ' ');
+84 -3
View File
@@ -37,11 +37,15 @@ chmod +x "${TARGET_BIN_DIR}/otelcol-contrib"
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF'
#!/usr/bin/env bash
echo "spacetimedb-cli 2.6.1"
cat <<'VERSION'
spacetime Path: /tmp/spacetimedb-cli
Commit: d220349adb7af7eefa810eb08a185609356b83f6
spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0;
VERSION
EOF
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF'
#!/usr/bin/env bash
echo "spacetimedb-standalone 2.6.1"
echo "spacetimedb-standalone 2.7.0"
EOF
chmod +x \
"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" \
@@ -58,7 +62,6 @@ if ! (
OTELCOL_TARGET_BIN="${TARGET_BIN_DIR}/otelcol-contrib" \
OTELCOL_VERSION="0.151.0" \
SPACETIME_ROOT="${SPACETIME_ROOT_DIR}" \
SPACETIME_EXPECTED_VERSION="2.6.1" \
"${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \
>"${OUTPUT_LOG}" 2>&1
); then
@@ -83,4 +86,82 @@ if grep -q "下载 " "${OUTPUT_LOG}"; then
exit 1
fi
BARE_TAG_ROOT_DIR="${TMP_ROOT}/bare-tag-stdb"
BARE_TAG_WORK_DIR="${TMP_ROOT}/bare-tag-workspace"
BARE_TAG_LOG="${TMP_ROOT}/bare-tag.log"
mkdir -p "${BARE_TAG_ROOT_DIR}/bin/current" "${BARE_TAG_WORK_DIR}"
cat >"${BARE_TAG_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF'
#!/usr/bin/env bash
cat <<'VERSION'
spacetime Path: /tmp/spacetimedb-cli
Commit: a08663c7b94688a2542577532d472f751e641f5b
spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0;
VERSION
EOF
cat >"${BARE_TAG_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF'
#!/usr/bin/env bash
echo "spacetimedb-standalone 2.7.0"
EOF
chmod +x \
"${BARE_TAG_ROOT_DIR}/bin/current/spacetimedb-cli" \
"${BARE_TAG_ROOT_DIR}/bin/current/spacetimedb-standalone"
if (
cd "${BARE_TAG_WORK_DIR}"
PATH="${FAKE_BIN_DIR}:${PATH}" \
WORKSPACE="${BARE_TAG_WORK_DIR}" \
PROVISION_TOOLS_DIR="provision-tools" \
PROVISION_DOWNLOADS_DIR="downloads" \
PROVISION_TOOLS_TMP_PARENT="${BARE_TAG_WORK_DIR}/.tmp/server-provision-tools" \
PROVISION_REQUIRE_LOCAL_DOWNLOADS="true" \
PREPARE_OTELCOL="false" \
SPACETIME_ROOT="${BARE_TAG_ROOT_DIR}" \
"${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \
>"${BARE_TAG_LOG}" 2>&1
); then
echo "[check-server-provision-tools] 裸 v2.7.0 tag 不应通过 hotfix3 复用门禁。" >&2
cat "${BARE_TAG_LOG}" >&2
exit 1
fi
grep -q "SpacetimeDB commit 不匹配" "${BARE_TAG_LOG}"
if grep -q "复用目标机已有 SpacetimeDB 安装" "${BARE_TAG_LOG}"; then
echo "[check-server-provision-tools] 裸 v2.7.0 tag 被错误复用。" >&2
cat "${BARE_TAG_LOG}" >&2
exit 1
fi
BARE_ARCHIVE_DIR="${TMP_ROOT}/bare-archive"
BARE_ARCHIVE_PATH="${TMP_ROOT}/bare-spacetime.tar.gz"
BARE_ARCHIVE_WORK_DIR="${TMP_ROOT}/bare-archive-workspace"
BARE_ARCHIVE_LOG="${TMP_ROOT}/bare-archive.log"
mkdir -p "${BARE_ARCHIVE_DIR}" "${BARE_ARCHIVE_WORK_DIR}"
cp \
"${BARE_TAG_ROOT_DIR}/bin/current/spacetimedb-cli" \
"${BARE_TAG_ROOT_DIR}/bin/current/spacetimedb-standalone" \
"${BARE_ARCHIVE_DIR}/"
tar -czf "${BARE_ARCHIVE_PATH}" -C "${BARE_ARCHIVE_DIR}" \
spacetimedb-cli spacetimedb-standalone
if (
cd "${BARE_ARCHIVE_WORK_DIR}"
PATH="${FAKE_BIN_DIR}:${PATH}" \
WORKSPACE="${BARE_ARCHIVE_WORK_DIR}" \
PROVISION_TOOLS_DIR="provision-tools" \
PROVISION_DOWNLOADS_DIR="downloads" \
PROVISION_TOOLS_TMP_PARENT="${BARE_ARCHIVE_WORK_DIR}/.tmp/server-provision-tools" \
PROVISION_REQUIRE_LOCAL_DOWNLOADS="true" \
PREPARE_OTELCOL="false" \
SPACETIME_ROOT="${TMP_ROOT}/missing-target-stdb" \
SPACETIME_ARCHIVE_PATH="${BARE_ARCHIVE_PATH}" \
"${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \
>"${BARE_ARCHIVE_LOG}" 2>&1
); then
echo "[check-server-provision-tools] 裸 v2.7.0 archive 不应通过 hotfix3 安装结果门禁。" >&2
cat "${BARE_ARCHIVE_LOG}" >&2
exit 1
fi
grep -q "安装结果 commit 不匹配" "${BARE_ARCHIVE_LOG}"
echo "[check-server-provision-tools] OK"

Some files were not shown because too many files have changed in this diff Show More