合并 master 最新更新
合入 master 的 BgFilter、CI、运维与现役平台改造。 保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。 修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。 同步冲突文档、锁文件和开发脚本。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
+956
-475
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [],',
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 示例。',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, ' ');
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user