添加gfilter专用worker (#103)
Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/103 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #103.
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);
|
||||
});
|
||||
}
|
||||
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。',
|
||||
@@ -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',
|
||||
@@ -1647,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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,8 +55,8 @@ export function parsePortRangeSpec(value) {
|
||||
throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`);
|
||||
}
|
||||
|
||||
if (end - start + 1 < 4) {
|
||||
throw new Error(`端口段至少需要 4 个端口: ${spec}`);
|
||||
if (end - start + 1 < 5) {
|
||||
throw new Error(`端口段至少需要 5 个端口: ${spec}`);
|
||||
}
|
||||
|
||||
return {start, end, label: `${start}-${end}`};
|
||||
@@ -118,6 +118,7 @@ export function mapDevPortsToPortRange(portRange) {
|
||||
apiPort: normalizedRange.start + 1,
|
||||
spacetimePort: normalizedRange.start + 2,
|
||||
adminWebPort: normalizedRange.start + 3,
|
||||
bgfilterWorkerPort: normalizedRange.start + 4,
|
||||
range: normalizedRange,
|
||||
};
|
||||
}
|
||||
@@ -569,6 +570,7 @@ export async function resolveDevStackPorts(config) {
|
||||
['api', config.api],
|
||||
['web', config.web],
|
||||
['adminWeb', config.adminWeb],
|
||||
['bgfilterWorker', config.bgfilterWorker],
|
||||
].filter(([, portConfig]) => Boolean(portConfig));
|
||||
const result = {};
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ async function reserveConsecutivePorts() {
|
||||
}
|
||||
|
||||
describe('dev stack port utils', () => {
|
||||
it('解析端口段并映射到四个 dev 端口', () => {
|
||||
it('解析端口段并映射到五个 dev 端口', () => {
|
||||
expect(parsePortRangeSpec('10000-10099')).toEqual({
|
||||
start: 10000,
|
||||
end: 10099,
|
||||
@@ -58,7 +58,11 @@ describe('dev stack port utils', () => {
|
||||
apiPort: 10001,
|
||||
spacetimePort: 10002,
|
||||
adminWebPort: 10003,
|
||||
bgfilterWorkerPort: 10004,
|
||||
});
|
||||
expect(() => parsePortRangeSpec('10000-10003')).toThrow(
|
||||
'端口段至少需要 5 个端口',
|
||||
);
|
||||
});
|
||||
|
||||
it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => {
|
||||
@@ -112,9 +116,10 @@ describe('dev stack port utils', () => {
|
||||
api: {host: '127.0.0.1', preferredPort: 0},
|
||||
web: {host: '127.0.0.1', preferredPort: 0},
|
||||
adminWeb: {host: '127.0.0.1', preferredPort: 0},
|
||||
bgfilterWorker: {host: '127.0.0.1', preferredPort: 0},
|
||||
});
|
||||
|
||||
expect(new Set(Object.values(resolvedPorts)).size).toBe(4);
|
||||
expect(new Set(Object.values(resolvedPorts)).size).toBe(5);
|
||||
});
|
||||
|
||||
it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => {
|
||||
|
||||
+339
-23
File diff suppressed because it is too large
Load Diff
+173
-1
@@ -19,6 +19,7 @@ import {
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildBgfilterWorkerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildFrontendProcessEnv,
|
||||
buildLocalRustProcessEnv,
|
||||
@@ -86,6 +87,26 @@ describe('dev scheduler argument routing', () => {
|
||||
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:8090');
|
||||
});
|
||||
|
||||
test('独立 BgFilter worker 命令解析内部监听地址', () => {
|
||||
const { command, explicitOptions, options } = parseArgs(
|
||||
[
|
||||
'bgfilter-worker',
|
||||
'--bgfilter-worker-host',
|
||||
'127.0.0.2',
|
||||
'--bgfilter-worker-port',
|
||||
'18083',
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(command).toBe('bgfilter-worker');
|
||||
expect(explicitOptions).toEqual(
|
||||
new Set(['bgfilterWorkerHost', 'bgfilterWorkerPort']),
|
||||
);
|
||||
expect(options.bgfilterWorkerHost).toBe('127.0.0.2');
|
||||
expect(options.bgfilterWorkerPort).toBe(18083);
|
||||
});
|
||||
|
||||
test('单独 dev:web 未显式指定 api 参数时沿用已有 Rust target', () => {
|
||||
const testEnv = {
|
||||
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
|
||||
@@ -130,7 +151,7 @@ describe('dev scheduler argument routing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
linuxTest('Linux 启动时按系统级端口段映射四个 dev 端口', async () => {
|
||||
linuxTest('Linux 启动时按系统级端口段映射五个 dev 端口', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
|
||||
try {
|
||||
const { command, explicitOptions, options } = parseArgs([], {
|
||||
@@ -157,7 +178,9 @@ describe('dev scheduler argument routing', () => {
|
||||
expect(runner.options.apiPort).toBe(22001);
|
||||
expect(runner.options.spacetimePort).toBe(22002);
|
||||
expect(runner.options.adminWebPort).toBe(22003);
|
||||
expect(runner.options.bgfilterWorkerPort).toBe(22004);
|
||||
expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001');
|
||||
expect(runner.state.bgfilterWorkerTarget).toBe('http://127.0.0.1:22004');
|
||||
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002');
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
@@ -197,6 +220,7 @@ describe('dev scheduler argument routing', () => {
|
||||
expect(runner.options.apiPort).toBe(22001);
|
||||
expect(runner.options.spacetimePort).toBe(22002);
|
||||
expect(runner.options.adminWebPort).toBe(22003);
|
||||
expect(runner.options.bgfilterWorkerPort).toBe(22004);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -234,6 +258,7 @@ describe('dev scheduler argument routing', () => {
|
||||
expect(runner.options.apiPort).toBe(8082);
|
||||
expect(runner.options.spacetimePort).toBe(3101);
|
||||
expect(runner.options.adminWebPort).toBe(3102);
|
||||
expect(runner.options.bgfilterWorkerPort).toBe(8083);
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
@@ -283,6 +308,46 @@ describe('dev scheduler api-server env', () => {
|
||||
expect(env.GENARRATIVE_PROCESS_ROLE).toBe('api');
|
||||
});
|
||||
|
||||
test('父 API 与独立 BgFilter worker 共享实际 URL 和内部 token', () => {
|
||||
const { options } = parseArgs([], {});
|
||||
options.bgfilterWorkerPort = 18083;
|
||||
const state = {
|
||||
spacetimeServer: 'http://127.0.0.1:3199',
|
||||
bgfilterWorkerTarget: 'http://127.0.0.1:18083',
|
||||
};
|
||||
const internalToken = 'local-bgfilter-token';
|
||||
|
||||
const apiEnv = buildApiServerProcessEnv({
|
||||
baseEnv: {},
|
||||
options,
|
||||
state,
|
||||
bgfilterInternalToken: internalToken,
|
||||
processRole: 'all',
|
||||
});
|
||||
const workerEnv = buildBgfilterWorkerProcessEnv({
|
||||
baseEnv: {},
|
||||
options,
|
||||
state,
|
||||
bgfilterInternalToken: internalToken,
|
||||
});
|
||||
|
||||
expect(apiEnv.GENARRATIVE_PROCESS_ROLE).toBe('all');
|
||||
expect(workerEnv.GENARRATIVE_PROCESS_ROLE).toBe('bgfilter-worker');
|
||||
expect(apiEnv.GENARRATIVE_BGFILTER_WORKER_BASE_URL).toBe(
|
||||
state.bgfilterWorkerTarget,
|
||||
);
|
||||
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_BASE_URL).toBe(
|
||||
state.bgfilterWorkerTarget,
|
||||
);
|
||||
expect(apiEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN).toBe(internalToken);
|
||||
expect(workerEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN).toBe(internalToken);
|
||||
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_HOST).toBe('127.0.0.1');
|
||||
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_PORT).toBe('18083');
|
||||
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY).toBe('16');
|
||||
expect(workerEnv.GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS).toBe('5000');
|
||||
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS).toBe('2048');
|
||||
});
|
||||
|
||||
test('Windows 本地 dev 自动注入已安装的 FFmpeg 路径', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-ffmpeg-'));
|
||||
try {
|
||||
@@ -340,6 +405,100 @@ describe('dev scheduler api-server env', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler Rust service orchestration', () => {
|
||||
test('Rust 双进程重启时先全部停止,再先 ready BgFilter、后 ready API', async () => {
|
||||
const { explicitOptions, options } = parseArgs([], {});
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
const events: string[] = [];
|
||||
runner.command = 'all';
|
||||
runner.windowsApiServerCleanupCompleted = true;
|
||||
runner.services = new Map([
|
||||
[
|
||||
'api-server',
|
||||
{
|
||||
stop: async () => events.push('stop-api'),
|
||||
start: async () => events.push('start-api'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'bgfilter-worker',
|
||||
{
|
||||
stop: async () => events.push('stop-bgfilter'),
|
||||
start: async () => events.push('start-bgfilter'),
|
||||
},
|
||||
],
|
||||
]);
|
||||
vi.spyOn(runner, 'waitForBgfilterWorker').mockImplementation(async () => {
|
||||
events.push('ready-bgfilter');
|
||||
});
|
||||
vi.spyOn(runner, 'waitForApiServer').mockImplementation(async () => {
|
||||
events.push('ready-api');
|
||||
});
|
||||
|
||||
await runner.restartRustServicePair();
|
||||
|
||||
expect(events).toEqual([
|
||||
'stop-api',
|
||||
'stop-bgfilter',
|
||||
'start-bgfilter',
|
||||
'ready-bgfilter',
|
||||
'start-api',
|
||||
'ready-api',
|
||||
]);
|
||||
});
|
||||
|
||||
test('dev:api-server 安全自动带起同 runner 的 BgFilter worker', async () => {
|
||||
const { explicitOptions, options } = parseArgs(['api-server'], {});
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
const startPair = vi
|
||||
.spyOn(runner, 'startRustServicePair')
|
||||
.mockResolvedValue(undefined);
|
||||
const startWatchers = vi
|
||||
.spyOn(runner, 'startWatchers')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await runner.startCommand('api-server');
|
||||
|
||||
expect(startPair).toHaveBeenCalledOnce();
|
||||
expect(startWatchers).toHaveBeenCalledWith([
|
||||
'api-server',
|
||||
'bgfilter-worker',
|
||||
]);
|
||||
});
|
||||
|
||||
test('完整栈只为两个 Rust 角色创建一套组合 watcher', () => {
|
||||
const { explicitOptions, options } = parseArgs(['--watch'], {});
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
runner.command = 'all';
|
||||
runner.registerServices();
|
||||
|
||||
try {
|
||||
runner.startWatchers(['api-server', 'bgfilter-worker']);
|
||||
expect(runner.watchers).toHaveLength(1);
|
||||
} finally {
|
||||
for (const watcher of runner.watchers) {
|
||||
watcher.close();
|
||||
}
|
||||
runner.watchers = [];
|
||||
}
|
||||
});
|
||||
|
||||
test('BgFilter worker 在 readiness 前退出时立即失败', async () => {
|
||||
const { explicitOptions, options } = parseArgs([], {});
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
runner.services = new Map([
|
||||
['bgfilter-worker', { runtime: { status: 'failed' } }],
|
||||
]);
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
status: 503,
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await expect(runner.waitForBgfilterWorker()).rejects.toThrow(
|
||||
'bgfilter-worker 在 readiness 前退出',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler local worker cleanup', () => {
|
||||
const expected = {
|
||||
expectedDatabase: 'xushi-p4wfr',
|
||||
@@ -472,6 +631,8 @@ describe('dev scheduler stack state file', () => {
|
||||
options: {
|
||||
apiHost: '127.0.0.1',
|
||||
apiPort: 8090,
|
||||
bgfilterWorkerHost: '127.0.0.1',
|
||||
bgfilterWorkerPort: 8091,
|
||||
webHost: '0.0.0.0',
|
||||
webPort: 3010,
|
||||
adminWebHost: '127.0.0.1',
|
||||
@@ -484,6 +645,7 @@ describe('dev scheduler stack state file', () => {
|
||||
},
|
||||
state: {
|
||||
apiTarget: 'http://127.0.0.1:8090',
|
||||
bgfilterWorkerTarget: 'http://127.0.0.1:8091',
|
||||
adminWebTargetHost: '127.0.0.1',
|
||||
spacetimeServer: 'http://127.0.0.1:3120',
|
||||
},
|
||||
@@ -528,6 +690,12 @@ describe('dev scheduler stack state file', () => {
|
||||
port: 8090,
|
||||
url: 'http://127.0.0.1:8090',
|
||||
});
|
||||
expect(snapshot.services['bgfilter-worker']).toMatchObject({
|
||||
status: 'idle',
|
||||
pid: null,
|
||||
port: 8091,
|
||||
url: 'http://127.0.0.1:8091',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1303,6 +1471,8 @@ spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0;
|
||||
'migration-secret-hash',
|
||||
GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET:
|
||||
'runtime-secret',
|
||||
GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'bgfilter-token',
|
||||
GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE: 'bgfilter-token-file',
|
||||
SAFE_VALUE: 'kept',
|
||||
},
|
||||
{ RUST_SERVER_TARGET: 'http://127.0.0.1:8082' },
|
||||
@@ -1318,6 +1488,8 @@ spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0;
|
||||
expect(env).not.toHaveProperty(
|
||||
'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET',
|
||||
);
|
||||
expect(env).not.toHaveProperty('GENARRATIVE_BGFILTER_INTERNAL_TOKEN');
|
||||
expect(env).not.toHaveProperty('GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE');
|
||||
expect(env.SAFE_VALUE).toBe('kept');
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,12 +18,14 @@ const DEFAULT_PUBLIC_PATHS = [
|
||||
|
||||
const DEFAULT_SERVICES = [
|
||||
'genarrative-api.service',
|
||||
'genarrative-bgfilter-worker.service',
|
||||
'genarrative-external-generation-controller.service',
|
||||
'spacetimedb.service',
|
||||
'nginx.service',
|
||||
];
|
||||
const PINGORA_DIRECT_SERVICES = [
|
||||
'genarrative-api.service',
|
||||
'genarrative-bgfilter-worker.service',
|
||||
'genarrative-external-generation-controller.service',
|
||||
'spacetimedb.service',
|
||||
'genarrative-pingora-gateway.service',
|
||||
@@ -37,6 +39,7 @@ function usage() {
|
||||
|
||||
Options:
|
||||
--api-base-url <url> API direct base URL, default http://127.0.0.1:8082
|
||||
--bgfilter-base-url <url> BgFilter worker base URL, default http://127.0.0.1:8083
|
||||
--spacetime-base-url <url> SpacetimeDB base URL, default http://127.0.0.1:3101
|
||||
--public-base-url <url> Nginx/public base URL, default http://127.0.0.1
|
||||
--public-host <host> Optional public Host header, useful when probing 127.0.0.1
|
||||
@@ -88,6 +91,9 @@ function parseArgs(argv) {
|
||||
apiBaseUrl:
|
||||
process.env.GENARRATIVE_HEALTH_PATROL_API_BASE_URL ||
|
||||
'http://127.0.0.1:8082',
|
||||
bgfilterBaseUrl:
|
||||
process.env.GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL ||
|
||||
'http://127.0.0.1:8083',
|
||||
spacetimeBaseUrl:
|
||||
process.env.GENARRATIVE_HEALTH_PATROL_SPACETIME_BASE_URL ||
|
||||
'http://127.0.0.1:3101',
|
||||
@@ -131,6 +137,9 @@ function parseArgs(argv) {
|
||||
case '--api-base-url':
|
||||
config.apiBaseUrl = requireValue(argv, ++index, arg);
|
||||
break;
|
||||
case '--bgfilter-base-url':
|
||||
config.bgfilterBaseUrl = requireValue(argv, ++index, arg);
|
||||
break;
|
||||
case '--spacetime-base-url':
|
||||
config.spacetimeBaseUrl = requireValue(argv, ++index, arg);
|
||||
break;
|
||||
@@ -693,6 +702,13 @@ async function main() {
|
||||
config,
|
||||
),
|
||||
);
|
||||
checks.push(
|
||||
await checkHttp(
|
||||
'bgfilter:/readyz',
|
||||
joinUrl(config.bgfilterBaseUrl, '/readyz'),
|
||||
config,
|
||||
),
|
||||
);
|
||||
checks.push(
|
||||
await checkHttp(
|
||||
'spacetimedb:/v1/ping',
|
||||
|
||||
Reference in New Issue
Block a user