1189e2856f
为容器与生产环境示例补齐钱包退款出箱默认变量 发布脚本自动补齐并创建钱包退款出箱运行目录 server provision 同步写入钱包退款出箱默认配置与目录 修复 production-api-deploy 检查对本地特权命令与负向日志的模拟
1643 lines
54 KiB
JavaScript
1643 lines
54 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const failures = [];
|
|
const tmpRoot = mkdtempSync(
|
|
path.join(tmpdir(), 'genarrative-production-api-deploy-'),
|
|
);
|
|
|
|
try {
|
|
main();
|
|
} finally {
|
|
rmSync(tmpRoot, { recursive: true, force: true });
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error('[check:production-api-deploy] FAILED');
|
|
for (const failure of failures) {
|
|
console.error(`- ${failure}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('[check:production-api-deploy] OK');
|
|
|
|
function main() {
|
|
assertDeployCopiesPingoraDirectReleaseDependencies();
|
|
assertDeployRestartsActivePingoraWhenArtifactIncluded();
|
|
assertDeployStartsInactivePingoraWhenArtifactIncluded();
|
|
assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded();
|
|
assertDeployRejectsPingoraPublicListenWhenArtifactIncluded();
|
|
assertDeployRejectsPingoraArtifactMissingManifestEntry();
|
|
assertMissingReleaseManifestFails();
|
|
assertReleaseManifestMissingApiArtifactFails();
|
|
assertDeployRejectsDotVersion();
|
|
assertDeployRejectsDotDotVersion();
|
|
assertDeployRejectsVersionStartingWithDot();
|
|
assertDeployRejectsExistingReleaseDirectory();
|
|
assertDeployRejectsDirectoryCurrentLink();
|
|
assertDeployRejectsRelativeReleaseRoot();
|
|
assertDeployRejectsRelativeCurrentLink();
|
|
assertDeployRejectsRelativeApiEnvFile();
|
|
assertDeployCleansStagingReleaseOnFailure();
|
|
assertDeployRejectsFinalReleaseRaceAndCleansStaging();
|
|
assertMissingBackupScriptFails();
|
|
assertMissingHealthPatrolScriptFails();
|
|
assertMissingPingoraCurrentReleaseAuditFails();
|
|
assertMissingPingoraDirectRehearsalStatusFails();
|
|
assertMissingPingoraCutoverStatusSnapshotFails();
|
|
assertMissingPingoraCutoverEvidenceBundleFails();
|
|
assertMissingPingoraCutoverCommandEvidenceFails();
|
|
assertMissingPingoraCutoverEvidenceVerifyFails();
|
|
assertMissingPingoraCutoverEvidenceAuditFails();
|
|
assertMissingHealthPatrolEnvCheckFails();
|
|
assertMissingPingoraReleaseReadinessFails();
|
|
assertMissingPingoraRealpathCanaryEnableFails();
|
|
assertMissingPingoraRealpathCanaryDisableFails();
|
|
assertMissingPingoraHealthPatrolEnvSwitchFails();
|
|
assertMissingPingoraGatewayEnvShadowSwitchFails();
|
|
assertMissingPingoraTlsCertSyncFails();
|
|
assertMissingEnvExamplesFails();
|
|
assertMissingPingoraDirectCheckFails();
|
|
assertMissingPingoraCanaryLiveFails();
|
|
assertMissingPingoraCanaryAccessLogParityFails();
|
|
}
|
|
|
|
function readOptionalCommandsLog(fixture) {
|
|
if (!existsSync(fixture.commandsLog)) {
|
|
return '';
|
|
}
|
|
return readFileSync(fixture.commandsLog, 'utf8');
|
|
}
|
|
|
|
function assertDeployCopiesPingoraDirectReleaseDependencies() {
|
|
const fixture = prepareFixture('with-direct-checks');
|
|
const result = runDeploy(fixture);
|
|
|
|
assertStatus(result, 0, '完整 fixture 应部署成功。');
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
assertIncludes(
|
|
result.stdout,
|
|
'[production-api-deploy] 完成:',
|
|
'部署成功时必须输出完成信息。',
|
|
);
|
|
|
|
const releaseDir = path.join(fixture.releaseRoot, fixture.version);
|
|
const currentTarget = readlinkSync(fixture.currentLink);
|
|
if (currentTarget !== releaseDir) {
|
|
failures.push(
|
|
`current link 应指向新 release。实际 ${currentTarget},预期 ${releaseDir}`,
|
|
);
|
|
}
|
|
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/deploy/pingora-direct-enable.sh'),
|
|
'current release 必须包含 Pingora 直连启用脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/deploy/pingora-direct-rollback.sh'),
|
|
'current release 必须包含 Pingora 直连回退脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/deploy/pingora-realpath-canary-enable.sh'),
|
|
'current release 必须包含 Pingora realpath canary 启用脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/deploy/pingora-realpath-canary-disable.sh'),
|
|
'current release 必须包含 Pingora realpath canary 关闭脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(
|
|
releaseDir,
|
|
'scripts/deploy/pingora-health-patrol-env-switch.mjs',
|
|
),
|
|
'current release 必须包含 Pingora health patrol env 切换脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(
|
|
releaseDir,
|
|
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
|
|
),
|
|
'current release 必须包含 Pingora gateway env shadow 切换脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/deploy/pingora-tls-cert-sync.mjs'),
|
|
'current release 必须包含 Pingora TLS 证书同步脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/check-pingora-direct-preflight.mjs'),
|
|
'current release 必须包含 Pingora 直连预检脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/check-production-health-patrol-env.mjs'),
|
|
'current release 必须包含健康巡检 env 复核脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/check-pingora-release-readiness.mjs'),
|
|
'current release 必须包含 Pingora release readiness 聚合门禁脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/ops/pingora-current-release-audit.mjs'),
|
|
'current release 必须包含 Pingora current release 自审脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/ops/pingora-direct-rehearsal-status.mjs'),
|
|
'current release 必须包含 Pingora 直连彩排状态脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/ops/pingora-cutover-status-snapshot.mjs'),
|
|
'current release 必须包含 Pingora 直连切换状态快照脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/ops/pingora-cutover-evidence-bundle.mjs'),
|
|
'current release 必须包含 Pingora 直连切换证据包脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/ops/pingora-cutover-command-evidence.mjs'),
|
|
'current release 必须包含 Pingora 直连切换命令证据脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/ops/pingora-cutover-evidence-verify.mjs'),
|
|
'current release 必须包含 Pingora 直连切换证据验真脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/ops/pingora-cutover-evidence-audit.mjs'),
|
|
'current release 必须包含 Pingora 直连切换证据根目录审计脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/check-pingora-direct-live.mjs'),
|
|
'current release 必须包含 Pingora 直连 live smoke 脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/check-pingora-canary-live.mjs'),
|
|
'current release 必须包含 Pingora canary live smoke 脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'scripts/check-pingora-canary-access-log-parity.mjs'),
|
|
'current release 必须包含 Pingora canary access log 对账脚本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'release-manifest.api-server.json'),
|
|
'current release 必须包含 API release manifest 副本。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'api-server.sha256'),
|
|
'current release 必须包含 api-server checksum,供随包 current release 自审校验。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'deploy/systemd/genarrative-pingora-gateway.service'),
|
|
'current release 必须包含 systemd 主 service 模板。',
|
|
);
|
|
assertFileExists(
|
|
path.join(
|
|
releaseDir,
|
|
'deploy/systemd/genarrative-pingora-gateway-direct-entry.conf',
|
|
),
|
|
'current release 必须包含 Pingora 直连 drop-in 模板。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'deploy/pingora/pingora-gateway.env.example'),
|
|
'current release 必须包含 Pingora env 示例。',
|
|
);
|
|
assertFileExists(
|
|
path.join(
|
|
releaseDir,
|
|
'deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf',
|
|
),
|
|
'current release 必须包含 Pingora 真实路径 canary Nginx snippet。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'deploy/env/health-patrol.env.example'),
|
|
'current release 必须包含健康巡检 env 示例。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'deploy/env/pingora-direct-live.env.example'),
|
|
'current release 必须包含 Pingora direct live env 示例。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'deploy/env/pingora-canary-live.env.example'),
|
|
'current release 必须包含 Pingora canary live env 示例。',
|
|
);
|
|
assertPingoraEnvProductionDefaults(
|
|
path.join(releaseDir, 'deploy/pingora/pingora-gateway.env.example'),
|
|
'current release 内 Pingora env 示例',
|
|
);
|
|
|
|
const copiedPreflight = readFileSync(
|
|
path.join(releaseDir, 'scripts/check-pingora-direct-preflight.mjs'),
|
|
'utf8',
|
|
);
|
|
assertIncludes(
|
|
copiedPreflight,
|
|
'deploy/pingora/pingora-gateway.env.example',
|
|
'复制后的 preflight 脚本必须仍能从 release root 推导 Pingora 配置。',
|
|
);
|
|
|
|
const apiEnv = readFileSync(fixture.apiEnvFile, 'utf8');
|
|
assertIncludes(
|
|
apiEnv,
|
|
'GENARRATIVE_SPACETIME_DATABASE=genarrative-prod',
|
|
'部署脚本必须写入 SpacetimeDB database。',
|
|
);
|
|
assertIncludes(
|
|
apiEnv,
|
|
'GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101',
|
|
'部署脚本必须写入 SpacetimeDB server URL。',
|
|
);
|
|
|
|
const commandsLog = readFileSync(fixture.commandsLog, 'utf8');
|
|
assertIncludes(
|
|
commandsLog,
|
|
'systemctl restart genarrative-api.service',
|
|
'部署脚本必须重启 API service。',
|
|
);
|
|
assertIncludes(
|
|
commandsLog,
|
|
'curl -fsS http://127.0.0.1:18082/readyz',
|
|
'部署脚本必须执行 readiness curl。',
|
|
);
|
|
|
|
if (existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署成功后应退出维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertDeployRestartsActivePingoraWhenArtifactIncluded() {
|
|
const fixture = prepareFixture('with-active-pingora-artifact');
|
|
addPingoraGatewayArtifact(fixture);
|
|
const result = runDeploy(fixture);
|
|
|
|
assertStatus(
|
|
result,
|
|
0,
|
|
'包含 Pingora 且 shadow service active 时应部署成功。',
|
|
);
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
|
|
const releaseDir = path.join(fixture.releaseRoot, fixture.version);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'pingora-gateway'),
|
|
'包含 Pingora 的发布包部署后 current release 必须包含网关二进制。',
|
|
);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'pingora-gateway.sha256'),
|
|
'包含 Pingora 的发布包部署后 current release 必须包含网关 checksum。',
|
|
);
|
|
|
|
const commandsLog = readFileSync(fixture.commandsLog, 'utf8');
|
|
assertIncludes(
|
|
commandsLog,
|
|
'systemctl cat genarrative-pingora-gateway.service',
|
|
'部署脚本看到 Pingora 产物后必须先读取 systemd 最终配置。',
|
|
);
|
|
assertIncludes(
|
|
commandsLog,
|
|
'systemctl restart genarrative-pingora-gateway.service',
|
|
'Pingora shadow service 已 active 时必须随 current release 切换 restart。',
|
|
);
|
|
assertIncludes(
|
|
result.stdout,
|
|
'发布包包含 Pingora,启动或重启 shadow 影子服务',
|
|
'Pingora shadow service 自动重启时必须输出明确提示。',
|
|
);
|
|
}
|
|
|
|
function assertDeployStartsInactivePingoraWhenArtifactIncluded() {
|
|
const fixture = prepareFixture('with-inactive-pingora-artifact');
|
|
addPingoraGatewayArtifact(fixture);
|
|
const result = runDeploy(fixture, { pingoraActive: false });
|
|
|
|
assertStatus(
|
|
result,
|
|
0,
|
|
'包含 Pingora 且 shadow service inactive 但仍是安全 shadow 配置时应部署成功。',
|
|
);
|
|
if (result.status !== 0) {
|
|
return;
|
|
}
|
|
|
|
const commandsLog = readFileSync(fixture.commandsLog, 'utf8');
|
|
assertIncludes(
|
|
commandsLog,
|
|
'systemctl cat genarrative-pingora-gateway.service',
|
|
'部署脚本看到 Pingora 产物后必须先读取 systemd 最终配置。',
|
|
);
|
|
assertIncludes(
|
|
commandsLog,
|
|
'systemctl restart genarrative-pingora-gateway.service',
|
|
'Pingora shadow service inactive 且 shadow 配置安全时必须随 current release 启动。',
|
|
);
|
|
assertIncludes(
|
|
commandsLog,
|
|
'systemctl is-active --quiet genarrative-pingora-gateway.service',
|
|
'启动或重启 Pingora shadow 后必须复核 active。',
|
|
);
|
|
assertIncludes(
|
|
result.stdout,
|
|
'发布包包含 Pingora,启动或重启 shadow 影子服务',
|
|
'Pingora shadow service 自动启动时必须输出明确提示。',
|
|
);
|
|
}
|
|
|
|
function assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded() {
|
|
const fixture = prepareFixture('with-direct-entry-pingora-artifact');
|
|
addPingoraGatewayArtifact(fixture);
|
|
const result = runDeploy(fixture, { pingoraDirectEntry: true });
|
|
|
|
if (result.status === 0) {
|
|
failures.push('包含 Pingora 但 systemd 已启用 direct-entry capability 时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'Pingora systemd 已包含 CAP_NET_BIND_SERVICE',
|
|
'direct-entry capability 存在时必须给出明确错误。',
|
|
);
|
|
const commandsLog = readOptionalCommandsLog(fixture);
|
|
if (
|
|
commandsLog.includes(
|
|
'systemctl restart genarrative-pingora-gateway.service',
|
|
)
|
|
) {
|
|
failures.push('direct-entry capability 存在时不能自动 restart Pingora。');
|
|
}
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('direct-entry capability 导致部署失败时必须保持维护模式。');
|
|
}
|
|
assertNoReleasePromoted(
|
|
fixture,
|
|
'direct-entry capability 导致部署失败时不能提升正式 release。',
|
|
);
|
|
}
|
|
|
|
function assertDeployRejectsPingoraPublicListenWhenArtifactIncluded() {
|
|
const fixture = prepareFixture('with-public-listen-pingora-artifact');
|
|
addPingoraGatewayArtifact(fixture);
|
|
writePingoraEnv(fixture, {
|
|
listen: '0.0.0.0:443',
|
|
});
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('包含 Pingora 但 env 已配置公网监听时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'Pingora 自动启动只允许 shadow 监听 127.0.0.1:18081',
|
|
'公网监听 env 存在时必须给出明确错误。',
|
|
);
|
|
const commandsLog = readOptionalCommandsLog(fixture);
|
|
if (
|
|
commandsLog.includes(
|
|
'systemctl restart genarrative-pingora-gateway.service',
|
|
)
|
|
) {
|
|
failures.push('公网监听 env 存在时不能自动 restart Pingora。');
|
|
}
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('公网监听 env 导致部署失败时必须保持维护模式。');
|
|
}
|
|
assertNoReleasePromoted(
|
|
fixture,
|
|
'公网监听 env 导致部署失败时不能提升正式 release。',
|
|
);
|
|
}
|
|
|
|
function assertDeployRejectsPingoraArtifactMissingManifestEntry() {
|
|
const fixture = prepareFixture('pingora-artifact-missing-manifest-entry');
|
|
addPingoraGatewayArtifact(fixture, { registerInManifest: false });
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布包包含 Pingora 但 manifest 未登记时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'release-manifest.json 缺少 pingora-gateway artifact',
|
|
'manifest 未登记 Pingora 时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('manifest 未登记 Pingora 导致部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingReleaseManifestFails() {
|
|
const fixture = prepareFixture('missing-release-manifest');
|
|
rmSync(path.join(fixture.sourceDir, 'release-manifest.json'));
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 release-manifest.json 时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 release-manifest.json',
|
|
'缺少 release-manifest.json 时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('缺少 release-manifest.json 导致部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertReleaseManifestMissingApiArtifactFails() {
|
|
const fixture = prepareFixture('release-manifest-missing-api-artifact');
|
|
writeFileSync(
|
|
path.join(fixture.sourceDir, 'release-manifest.json'),
|
|
`${JSON.stringify({ version: fixture.version, artifacts: [] }, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('release manifest 缺少 api-server artifact 时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'release-manifest.json 缺少 api-server artifact',
|
|
'manifest 缺少 api-server artifact 时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('manifest 缺少 api-server artifact 导致部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertDeployRejectsDotVersion() {
|
|
const fixture = prepareFixture('dot-version');
|
|
const result = runDeploy(fixture, { version: '.' });
|
|
|
|
if (result.status === 0) {
|
|
failures.push('--version=. 时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'--version 必须以数字或字母开头',
|
|
'--version=. 时必须给出明确错误。',
|
|
);
|
|
if (existsSync(fixture.maintenanceFile)) {
|
|
failures.push('--version=. 且未进入部署阶段时不应开启维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertDeployRejectsDotDotVersion() {
|
|
const fixture = prepareFixture('dot-dot-version');
|
|
const result = runDeploy(fixture, { version: '..' });
|
|
|
|
if (result.status === 0) {
|
|
failures.push('--version=.. 时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'--version 必须以数字或字母开头',
|
|
'--version=.. 时必须给出明确错误。',
|
|
);
|
|
if (existsSync(fixture.maintenanceFile)) {
|
|
failures.push('--version=.. 且未进入部署阶段时不应开启维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertDeployRejectsVersionStartingWithDot() {
|
|
const fixture = prepareFixture('dot-prefix-version');
|
|
const result = runDeploy(fixture, { version: '.hidden-release' });
|
|
|
|
if (result.status === 0) {
|
|
failures.push('--version 以点开头时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'--version 必须以数字或字母开头',
|
|
'--version 以点开头时必须给出明确错误。',
|
|
);
|
|
if (existsSync(fixture.maintenanceFile)) {
|
|
failures.push('--version 以点开头且未进入部署阶段时不应开启维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertDeployRejectsExistingReleaseDirectory() {
|
|
const fixture = prepareFixture('existing-release-directory');
|
|
const releaseDir = path.join(fixture.releaseRoot, fixture.version);
|
|
mkdirSync(releaseDir, { recursive: true });
|
|
writeFileSync(path.join(releaseDir, 'old-file'), 'old\n', 'utf8');
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('目标 release 目录已存在时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'目标 release 已存在,拒绝覆盖或合并旧文件',
|
|
'目标 release 目录已存在时必须给出明确错误。',
|
|
);
|
|
if (existsSync(fixture.maintenanceFile)) {
|
|
failures.push('目标 release 目录已存在且未进入部署阶段时不应开启维护模式。');
|
|
}
|
|
assertFileExists(
|
|
path.join(releaseDir, 'old-file'),
|
|
'拒绝覆盖既有 release 时必须保留原目录。',
|
|
);
|
|
}
|
|
|
|
function assertDeployRejectsDirectoryCurrentLink() {
|
|
const fixture = prepareFixture('directory-current-link');
|
|
mkdirSync(fixture.currentLink, { recursive: true });
|
|
writeFileSync(path.join(fixture.currentLink, 'old-file'), 'old\n', 'utf8');
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('current 路径已存在但不是符号链接时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'current 链接路径已存在但不是符号链接,拒绝覆盖',
|
|
'current 路径不是符号链接时必须给出明确错误。',
|
|
);
|
|
if (existsSync(fixture.maintenanceFile)) {
|
|
failures.push('current 路径不是符号链接且未进入部署阶段时不应开启维护模式。');
|
|
}
|
|
assertFileExists(
|
|
path.join(fixture.currentLink, 'old-file'),
|
|
'拒绝覆盖目录型 current 时必须保留原目录内容。',
|
|
);
|
|
}
|
|
|
|
function assertDeployRejectsRelativeReleaseRoot() {
|
|
const fixture = prepareFixture('relative-release-root');
|
|
const result = runDeploy(fixture, { releaseRoot: 'relative/releases' });
|
|
|
|
if (result.status === 0) {
|
|
failures.push('--release-root 使用相对路径时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'--release-root 必须使用绝对路径',
|
|
'--release-root 使用相对路径时必须给出明确错误。',
|
|
);
|
|
if (existsSync(fixture.maintenanceFile)) {
|
|
failures.push('--release-root 相对路径且未进入部署阶段时不应开启维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertDeployRejectsRelativeCurrentLink() {
|
|
const fixture = prepareFixture('relative-current-link');
|
|
const result = runDeploy(fixture, { currentLink: 'relative/current' });
|
|
|
|
if (result.status === 0) {
|
|
failures.push('--current-link 使用相对路径时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'--current-link 必须使用绝对路径',
|
|
'--current-link 使用相对路径时必须给出明确错误。',
|
|
);
|
|
if (existsSync(fixture.maintenanceFile)) {
|
|
failures.push('--current-link 相对路径且未进入部署阶段时不应开启维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertDeployRejectsRelativeApiEnvFile() {
|
|
const fixture = prepareFixture('relative-api-env-file');
|
|
const result = runDeploy(fixture, { apiEnvFile: 'relative/api-server.env' });
|
|
|
|
if (result.status === 0) {
|
|
failures.push('--api-env-file 使用相对路径时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'--api-env-file 必须使用绝对路径',
|
|
'--api-env-file 使用相对路径时必须给出明确错误。',
|
|
);
|
|
if (existsSync(fixture.maintenanceFile)) {
|
|
failures.push('--api-env-file 相对路径且未进入部署阶段时不应开启维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertDeployCleansStagingReleaseOnFailure() {
|
|
const fixture = prepareFixture('cleans-staging-on-failure');
|
|
rmSync(path.join(fixture.sourceDir, 'scripts/database-backup-to-oss.mjs'));
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('缺少数据库备份脚本时部署必须失败。');
|
|
}
|
|
const releaseDir = path.join(fixture.releaseRoot, fixture.version);
|
|
if (existsSync(releaseDir)) {
|
|
failures.push('部署失败时不应留下正式 release 目录。');
|
|
}
|
|
const entries = existsSync(fixture.releaseRoot)
|
|
? readDirNames(fixture.releaseRoot)
|
|
: [];
|
|
const stagingEntries = entries.filter((entry) =>
|
|
entry.includes(`${fixture.version}.staging`),
|
|
);
|
|
if (stagingEntries.length > 0) {
|
|
failures.push(`部署失败时不应留下 staging release: ${stagingEntries.join(', ')}`);
|
|
}
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertDeployRejectsFinalReleaseRaceAndCleansStaging() {
|
|
const fixture = prepareFixture('final-release-race');
|
|
const result = runDeploy(fixture, { createReleaseDuringCopy: true });
|
|
|
|
if (result.status === 0) {
|
|
failures.push('最终提升前目标 release 目录被外部创建时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'目标 release 在发布过程中出现,拒绝合并 staging',
|
|
'最终提升前目标 release 目录被外部创建时必须给出明确错误。',
|
|
);
|
|
|
|
const releaseDir = path.join(fixture.releaseRoot, fixture.version);
|
|
assertFileExists(
|
|
path.join(releaseDir, 'raced-file'),
|
|
'部署脚本拒绝竞态目标 release 时必须保留外部创建的目录。',
|
|
);
|
|
if (existsSync(path.join(releaseDir, 'api-server'))) {
|
|
failures.push('目标 release 竞态出现后不应把 staging 内容合并进去。');
|
|
}
|
|
const entries = existsSync(fixture.releaseRoot)
|
|
? readDirNames(fixture.releaseRoot)
|
|
: [];
|
|
const stagingEntries = entries.filter((entry) =>
|
|
entry.includes(`${fixture.version}.staging`),
|
|
);
|
|
if (stagingEntries.length > 0) {
|
|
failures.push(`目标 release 竞态失败后不应留下 staging release: ${stagingEntries.join(', ')}`);
|
|
}
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('目标 release 竞态失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraDirectCheckFails() {
|
|
const fixture = prepareFixture('missing-direct-live');
|
|
rmSync(path.join(fixture.sourceDir, 'scripts/check-pingora-direct-live.mjs'));
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 direct live smoke 脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora 直连 live smoke 脚本',
|
|
'缺少 direct live smoke 脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraCanaryLiveFails() {
|
|
const fixture = prepareFixture('missing-canary-live');
|
|
rmSync(path.join(fixture.sourceDir, 'scripts/check-pingora-canary-live.mjs'));
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 canary live smoke 脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora canary live smoke 脚本',
|
|
'缺少 canary live smoke 脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraCanaryAccessLogParityFails() {
|
|
const fixture = prepareFixture('missing-canary-log-parity');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/check-pingora-canary-access-log-parity.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 canary access log 对账脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora canary access log 对账脚本',
|
|
'缺少 canary access log 对账脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingBackupScriptFails() {
|
|
const fixture = prepareFixture('missing-backup-script');
|
|
rmSync(path.join(fixture.sourceDir, 'scripts/database-backup-to-oss.mjs'));
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少数据库备份脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少数据库备份脚本',
|
|
'缺少数据库备份脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingHealthPatrolScriptFails() {
|
|
const fixture = prepareFixture('missing-health-patrol-script');
|
|
rmSync(
|
|
path.join(fixture.sourceDir, 'scripts/ops/production-health-patrol.mjs'),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少生产健康巡检脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少生产健康巡检脚本',
|
|
'缺少生产健康巡检脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraCurrentReleaseAuditFails() {
|
|
const fixture = prepareFixture('missing-pingora-current-release-audit');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/ops/pingora-current-release-audit.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 Pingora current release 自审脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora current release 自审脚本',
|
|
'缺少 Pingora current release 自审脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraDirectRehearsalStatusFails() {
|
|
const fixture = prepareFixture('missing-pingora-direct-rehearsal-status');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/ops/pingora-direct-rehearsal-status.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 Pingora 直连彩排状态脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora 直连彩排状态脚本',
|
|
'缺少 Pingora 直连彩排状态脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraCutoverStatusSnapshotFails() {
|
|
const fixture = prepareFixture('missing-pingora-cutover-status-snapshot');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/ops/pingora-cutover-status-snapshot.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 Pingora 直连切换状态快照脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora 直连切换状态快照脚本',
|
|
'缺少 Pingora 直连切换状态快照脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraCutoverEvidenceBundleFails() {
|
|
const fixture = prepareFixture('missing-pingora-cutover-evidence-bundle');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/ops/pingora-cutover-evidence-bundle.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 Pingora 直连切换证据包脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora 直连切换证据包脚本',
|
|
'缺少 Pingora 直连切换证据包脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraCutoverCommandEvidenceFails() {
|
|
const fixture = prepareFixture('missing-pingora-cutover-command-evidence');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/ops/pingora-cutover-command-evidence.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 Pingora 直连切换命令证据脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora 直连切换命令证据脚本',
|
|
'缺少 Pingora 直连切换命令证据脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraCutoverEvidenceVerifyFails() {
|
|
const fixture = prepareFixture('missing-pingora-cutover-evidence-verify');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/ops/pingora-cutover-evidence-verify.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 Pingora 直连切换证据验真脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora 直连切换证据验真脚本',
|
|
'缺少 Pingora 直连切换证据验真脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraCutoverEvidenceAuditFails() {
|
|
const fixture = prepareFixture('missing-pingora-cutover-evidence-audit');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/ops/pingora-cutover-evidence-audit.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push(
|
|
'发布产物缺少 Pingora 直连切换证据根目录审计脚本时部署必须失败。',
|
|
);
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora 直连切换证据根目录审计脚本',
|
|
'缺少 Pingora 直连切换证据根目录审计脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingHealthPatrolEnvCheckFails() {
|
|
const fixture = prepareFixture('missing-health-patrol-env-check');
|
|
rmSync(
|
|
path.join(fixture.sourceDir, 'scripts/check-production-health-patrol-env.mjs'),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少生产健康巡检 env 复核脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少生产健康巡检 env 复核脚本',
|
|
'缺少生产健康巡检 env 复核脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraReleaseReadinessFails() {
|
|
const fixture = prepareFixture('missing-pingora-release-readiness');
|
|
rmSync(
|
|
path.join(fixture.sourceDir, 'scripts/check-pingora-release-readiness.mjs'),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push(
|
|
'发布产物缺少 Pingora release readiness 聚合门禁脚本时部署必须失败。',
|
|
);
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora release readiness 聚合门禁脚本',
|
|
'缺少 Pingora release readiness 聚合门禁脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraHealthPatrolEnvSwitchFails() {
|
|
const fixture = prepareFixture('missing-pingora-health-patrol-env-switch');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/deploy/pingora-health-patrol-env-switch.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 Pingora health patrol env 切换脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora health patrol env 切换脚本',
|
|
'缺少 Pingora health patrol env 切换脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraGatewayEnvShadowSwitchFails() {
|
|
const fixture = prepareFixture('missing-pingora-gateway-env-shadow-switch');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push(
|
|
'发布产物缺少 Pingora gateway env shadow 切换脚本时部署必须失败。',
|
|
);
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora gateway env shadow 切换脚本',
|
|
'缺少 Pingora gateway env shadow 切换脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraRealpathCanaryEnableFails() {
|
|
const fixture = prepareFixture('missing-pingora-realpath-canary-enable');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/deploy/pingora-realpath-canary-enable.sh',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 Pingora realpath canary 启用脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora realpath canary 启用脚本',
|
|
'缺少 Pingora realpath canary 启用脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraRealpathCanaryDisableFails() {
|
|
const fixture = prepareFixture('missing-pingora-realpath-canary-disable');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/deploy/pingora-realpath-canary-disable.sh',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 Pingora realpath canary 关闭脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora realpath canary 关闭脚本',
|
|
'缺少 Pingora realpath canary 关闭脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingPingoraTlsCertSyncFails() {
|
|
const fixture = prepareFixture('missing-pingora-tls-cert-sync');
|
|
rmSync(
|
|
path.join(
|
|
fixture.sourceDir,
|
|
'scripts/deploy/pingora-tls-cert-sync.mjs',
|
|
),
|
|
);
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少 Pingora TLS 证书同步脚本时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少 Pingora TLS 证书同步脚本',
|
|
'缺少 Pingora TLS 证书同步脚本时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function assertMissingEnvExamplesFails() {
|
|
const fixture = prepareFixture('missing-env-examples');
|
|
rmSync(path.join(fixture.sourceDir, 'deploy/env'), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
const result = runDeploy(fixture);
|
|
|
|
if (result.status === 0) {
|
|
failures.push('发布产物缺少环境变量示例目录时部署必须失败。');
|
|
}
|
|
assertIncludes(
|
|
result.stderr,
|
|
'发布产物缺少环境变量示例目录',
|
|
'缺少环境变量示例目录时必须给出明确错误。',
|
|
);
|
|
if (!existsSync(fixture.maintenanceFile)) {
|
|
failures.push('部署失败时必须保持维护模式。');
|
|
}
|
|
}
|
|
|
|
function prepareFixture(name) {
|
|
const root = path.join(tmpRoot, name);
|
|
const sourceDir = path.join(root, 'source');
|
|
const releaseRoot = path.join(root, 'releases');
|
|
const currentLink = path.join(root, 'current');
|
|
const apiEnvFile = path.join(root, 'etc', 'api-server.env');
|
|
const pingoraEnvFile = path.join(root, 'etc', 'pingora-gateway.env');
|
|
const maintenanceFile = path.join(root, 'maintenance', 'enabled');
|
|
const fakeBin = path.join(root, 'bin');
|
|
const commandsLog = path.join(root, 'commands.log');
|
|
const workerStateFile = path.join(root, 'worker-service-enabled');
|
|
const pingoraStateFile = path.join(root, 'pingora-service-active');
|
|
const version = `20260614-${name}`;
|
|
|
|
mkdirSync(sourceDir, { recursive: true });
|
|
mkdirSync(fakeBin, { recursive: true });
|
|
mkdirSync(path.dirname(apiEnvFile), { recursive: true });
|
|
mkdirSync(path.join(sourceDir, 'scripts/deploy'), { recursive: true });
|
|
mkdirSync(path.join(sourceDir, 'scripts/ops'), { recursive: true });
|
|
mkdirSync(path.join(sourceDir, 'deploy/systemd'), { recursive: true });
|
|
mkdirSync(path.join(sourceDir, 'deploy/pingora'), { recursive: true });
|
|
mkdirSync(path.join(sourceDir, 'deploy/env'), { recursive: true });
|
|
mkdirSync(path.join(sourceDir, 'deploy/nginx/snippets'), { recursive: true });
|
|
|
|
writeFileSync(
|
|
path.join(sourceDir, 'api-server'),
|
|
'#!/usr/bin/env bash\n',
|
|
'utf8',
|
|
);
|
|
writeFileSync(
|
|
apiEnvFile,
|
|
[
|
|
'GENARRATIVE_TRACKING_OUTBOX_ENABLED=false',
|
|
`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR=${path.join(root, 'wallet-refund-outbox')}`,
|
|
'GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000',
|
|
'',
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
writePingoraEnv({ pingoraEnvFile });
|
|
chmodExecutable(path.join(sourceDir, 'api-server'));
|
|
writeSha256(sourceDir, 'api-server');
|
|
writeFileSync(
|
|
path.join(sourceDir, 'release-manifest.json'),
|
|
`${JSON.stringify(
|
|
{
|
|
version,
|
|
artifacts: [
|
|
{
|
|
component: 'api-server',
|
|
path: 'api-server',
|
|
checksum_path: 'api-server.sha256',
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
'utf8',
|
|
);
|
|
writeFileSync(
|
|
path.join(sourceDir, 'scripts/database-backup-to-oss.mjs'),
|
|
'console.log("backup");\n',
|
|
'utf8',
|
|
);
|
|
writeFileSync(
|
|
path.join(sourceDir, 'scripts/ops/production-health-patrol.mjs'),
|
|
'console.log("patrol");\n',
|
|
'utf8',
|
|
);
|
|
copyFile(
|
|
'scripts/ops/pingora-current-release-audit.mjs',
|
|
path.join(sourceDir, 'scripts/ops/pingora-current-release-audit.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/ops/pingora-direct-rehearsal-status.mjs',
|
|
path.join(sourceDir, 'scripts/ops/pingora-direct-rehearsal-status.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/ops/pingora-cutover-status-snapshot.mjs',
|
|
path.join(sourceDir, 'scripts/ops/pingora-cutover-status-snapshot.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/ops/pingora-cutover-evidence-bundle.mjs',
|
|
path.join(sourceDir, 'scripts/ops/pingora-cutover-evidence-bundle.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/ops/pingora-cutover-command-evidence.mjs',
|
|
path.join(sourceDir, 'scripts/ops/pingora-cutover-command-evidence.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/ops/pingora-cutover-evidence-verify.mjs',
|
|
path.join(sourceDir, 'scripts/ops/pingora-cutover-evidence-verify.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/ops/pingora-cutover-evidence-audit.mjs',
|
|
path.join(sourceDir, 'scripts/ops/pingora-cutover-evidence-audit.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/deploy/production-api-deploy.sh',
|
|
path.join(sourceDir, 'scripts/deploy/production-api-deploy.sh'),
|
|
);
|
|
copyFile(
|
|
'scripts/deploy/maintenance-on.sh',
|
|
path.join(sourceDir, 'scripts/deploy/maintenance-on.sh'),
|
|
);
|
|
copyFile(
|
|
'scripts/deploy/maintenance-off.sh',
|
|
path.join(sourceDir, 'scripts/deploy/maintenance-off.sh'),
|
|
);
|
|
copyFile(
|
|
'scripts/deploy/pingora-direct-enable.sh',
|
|
path.join(sourceDir, 'scripts/deploy/pingora-direct-enable.sh'),
|
|
);
|
|
copyFile(
|
|
'scripts/deploy/pingora-direct-rollback.sh',
|
|
path.join(sourceDir, 'scripts/deploy/pingora-direct-rollback.sh'),
|
|
);
|
|
copyFile(
|
|
'scripts/deploy/pingora-realpath-canary-enable.sh',
|
|
path.join(sourceDir, 'scripts/deploy/pingora-realpath-canary-enable.sh'),
|
|
);
|
|
copyFile(
|
|
'scripts/deploy/pingora-realpath-canary-disable.sh',
|
|
path.join(sourceDir, 'scripts/deploy/pingora-realpath-canary-disable.sh'),
|
|
);
|
|
copyFile(
|
|
'scripts/deploy/pingora-health-patrol-env-switch.mjs',
|
|
path.join(sourceDir, 'scripts/deploy/pingora-health-patrol-env-switch.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
|
|
path.join(sourceDir, 'scripts/deploy/pingora-gateway-env-shadow-switch.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/deploy/pingora-tls-cert-sync.mjs',
|
|
path.join(sourceDir, 'scripts/deploy/pingora-tls-cert-sync.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/check-production-health-patrol-env.mjs',
|
|
path.join(sourceDir, 'scripts/check-production-health-patrol-env.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/check-pingora-release-readiness.mjs',
|
|
path.join(sourceDir, 'scripts/check-pingora-release-readiness.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/check-pingora-direct-preflight.mjs',
|
|
path.join(sourceDir, 'scripts/check-pingora-direct-preflight.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/check-pingora-direct-live.mjs',
|
|
path.join(sourceDir, 'scripts/check-pingora-direct-live.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/check-pingora-canary-live.mjs',
|
|
path.join(sourceDir, 'scripts/check-pingora-canary-live.mjs'),
|
|
);
|
|
copyFile(
|
|
'scripts/check-pingora-canary-access-log-parity.mjs',
|
|
path.join(sourceDir, 'scripts/check-pingora-canary-access-log-parity.mjs'),
|
|
);
|
|
copyFile(
|
|
'deploy/systemd/genarrative-pingora-gateway.service',
|
|
path.join(sourceDir, 'deploy/systemd/genarrative-pingora-gateway.service'),
|
|
);
|
|
copyFile(
|
|
'deploy/systemd/genarrative-pingora-gateway-direct-entry.conf',
|
|
path.join(
|
|
sourceDir,
|
|
'deploy/systemd/genarrative-pingora-gateway-direct-entry.conf',
|
|
),
|
|
);
|
|
copyFile(
|
|
'deploy/pingora/pingora-gateway.env.example',
|
|
path.join(sourceDir, 'deploy/pingora/pingora-gateway.env.example'),
|
|
);
|
|
copyFile(
|
|
'deploy/env/health-patrol.env.example',
|
|
path.join(sourceDir, 'deploy/env/health-patrol.env.example'),
|
|
);
|
|
copyFile(
|
|
'deploy/env/pingora-direct-live.env.example',
|
|
path.join(sourceDir, 'deploy/env/pingora-direct-live.env.example'),
|
|
);
|
|
copyFile(
|
|
'deploy/env/pingora-canary-live.env.example',
|
|
path.join(sourceDir, 'deploy/env/pingora-canary-live.env.example'),
|
|
);
|
|
copyFile(
|
|
'deploy/nginx/snippets/genarrative-pingora-canary.conf',
|
|
path.join(
|
|
sourceDir,
|
|
'deploy/nginx/snippets/genarrative-pingora-canary.conf',
|
|
),
|
|
);
|
|
copyFile(
|
|
'deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf',
|
|
path.join(
|
|
sourceDir,
|
|
'deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf',
|
|
),
|
|
);
|
|
chmodExecutable(
|
|
path.join(sourceDir, 'scripts/deploy/production-api-deploy.sh'),
|
|
);
|
|
chmodExecutable(path.join(sourceDir, 'scripts/deploy/maintenance-on.sh'));
|
|
chmodExecutable(path.join(sourceDir, 'scripts/deploy/maintenance-off.sh'));
|
|
|
|
writeFileSync(
|
|
path.join(fakeBin, 'systemctl'),
|
|
[
|
|
'#!/usr/bin/env bash',
|
|
'set -euo pipefail',
|
|
`printf 'systemctl %s\\n' "$*" >> ${shellQuote(commandsLog)}`,
|
|
'worker_state_file="${FAKE_WORKER_STATE_FILE}"',
|
|
'pingora_state_file="${FAKE_PINGORA_STATE_FILE}"',
|
|
'if [[ "$1" == "cat" && "${2:-}" == "genarrative-pingora-gateway.service" ]]; then',
|
|
' printf "[Service]\\n"',
|
|
' printf "EnvironmentFile=%s\\n" "${FAKE_PINGORA_ENV_FILE}"',
|
|
' printf "ExecStart=/opt/genarrative/current/pingora-gateway\\n"',
|
|
' if [[ "${FAKE_PINGORA_DIRECT_ENTRY:-false}" == "true" ]]; then',
|
|
' printf "AmbientCapabilities=CAP_NET_BIND_SERVICE\\n"',
|
|
' printf "CapabilityBoundingSet=CAP_NET_BIND_SERVICE\\n"',
|
|
' fi',
|
|
' exit 0',
|
|
'fi',
|
|
'if [[ "$1" == "list-units" ]]; then',
|
|
' pattern="${@: -1}"',
|
|
' if [[ "${pattern}" == "genarrative-external-generation-worker@*.service" && -f "${worker_state_file}" ]]; then',
|
|
' printf "genarrative-external-generation-worker@1.service loaded active running Genarrative external generation worker\\n"',
|
|
' fi',
|
|
' exit 0',
|
|
'fi',
|
|
'if [[ "$1 $2" == "enable --now" && "${3:-}" == "genarrative-external-generation-worker@1.service" ]]; then',
|
|
' printf "enabled\\n" > "${worker_state_file}"',
|
|
' exit 0',
|
|
'fi',
|
|
'if [[ "$1 $2 ${3:-}" == "is-active --quiet genarrative-pingora-gateway.service" ]]; then',
|
|
' if [[ "${FAKE_PINGORA_ACTIVE:-true}" == "true" || -f "${pingora_state_file}" ]]; then',
|
|
' exit 0',
|
|
' fi',
|
|
' exit 3',
|
|
'fi',
|
|
'if [[ "$1" == "restart" && "${2:-}" == "genarrative-pingora-gateway.service" ]]; then',
|
|
' printf "active\\n" > "${pingora_state_file}"',
|
|
' exit 0',
|
|
'fi',
|
|
'exit 0',
|
|
'',
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
writeFileSync(
|
|
path.join(fakeBin, 'curl'),
|
|
[
|
|
'#!/usr/bin/env bash',
|
|
`printf 'curl %s\\n' "$*" >> ${shellQuote(commandsLog)}`,
|
|
'exit 0',
|
|
'',
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
writeFileSync(
|
|
path.join(fakeBin, 'cp'),
|
|
[
|
|
'#!/usr/bin/env bash',
|
|
'set -euo pipefail',
|
|
'/usr/bin/cp "$@"',
|
|
'if [[ "${FAKE_CREATE_RELEASE_DURING_COPY:-false}" == "true" ]]; then',
|
|
' marker="${FAKE_RELEASE_ROOT}/.${FAKE_RELEASE_VERSION}.race-created"',
|
|
' if [[ ! -e "${marker}" ]]; then',
|
|
' mkdir -p "${FAKE_RELEASE_ROOT}/${FAKE_RELEASE_VERSION}"',
|
|
' printf "race\\n" > "${FAKE_RELEASE_ROOT}/${FAKE_RELEASE_VERSION}/raced-file"',
|
|
' printf "created\\n" > "${marker}"',
|
|
' fi',
|
|
'fi',
|
|
'',
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
writeFileSync(
|
|
path.join(fakeBin, 'sudo'),
|
|
[
|
|
'#!/usr/bin/env bash',
|
|
'set -euo pipefail',
|
|
'if [[ "${1:-}" == "-n" ]]; then',
|
|
' shift',
|
|
'fi',
|
|
'if [[ "${1:-}" == "true" ]]; then',
|
|
' exit 0',
|
|
'fi',
|
|
'if [[ "${1:-}" == "install" && "${2:-}" == "-d" ]]; then',
|
|
' path="${@: -1}"',
|
|
' mkdir -p "${path}"',
|
|
' exit 0',
|
|
'fi',
|
|
'exec "$@"',
|
|
'',
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
chmodExecutable(path.join(fakeBin, 'systemctl'));
|
|
chmodExecutable(path.join(fakeBin, 'curl'));
|
|
chmodExecutable(path.join(fakeBin, 'cp'));
|
|
chmodExecutable(path.join(fakeBin, 'sudo'));
|
|
|
|
return {
|
|
root,
|
|
sourceDir,
|
|
releaseRoot,
|
|
currentLink,
|
|
apiEnvFile,
|
|
pingoraEnvFile,
|
|
maintenanceFile,
|
|
fakeBin,
|
|
commandsLog,
|
|
workerStateFile,
|
|
pingoraStateFile,
|
|
version,
|
|
};
|
|
}
|
|
|
|
function writePingoraEnv(fixture, options = {}) {
|
|
const filePath = fixture.pingoraEnvFile;
|
|
const lines = [
|
|
`GENARRATIVE_PINGORA_GATEWAY_LISTEN=${options.listen ?? '127.0.0.1:18081'}`,
|
|
];
|
|
if (options.tlsListen) {
|
|
lines.push(`GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN=${options.tlsListen}`);
|
|
}
|
|
if (options.redirectListen) {
|
|
lines.push(
|
|
`GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN=${options.redirectListen}`,
|
|
);
|
|
}
|
|
writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
|
|
}
|
|
|
|
function addPingoraGatewayArtifact(fixture, options = {}) {
|
|
writeFileSync(
|
|
path.join(fixture.sourceDir, 'pingora-gateway'),
|
|
'#!/usr/bin/env bash\n',
|
|
'utf8',
|
|
);
|
|
chmodExecutable(path.join(fixture.sourceDir, 'pingora-gateway'));
|
|
writeSha256(fixture.sourceDir, 'pingora-gateway');
|
|
if (options.registerInManifest === false) {
|
|
return;
|
|
}
|
|
const manifestPath = path.join(fixture.sourceDir, 'release-manifest.json');
|
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
manifest.artifacts = Array.isArray(manifest.artifacts)
|
|
? manifest.artifacts
|
|
: [];
|
|
manifest.artifacts.push({
|
|
component: 'pingora-gateway',
|
|
path: 'pingora-gateway',
|
|
checksum_path: 'pingora-gateway.sha256',
|
|
});
|
|
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
}
|
|
|
|
function runDeploy(fixture, options = {}) {
|
|
const deployScript = path.join(
|
|
fixture.sourceDir,
|
|
'scripts/deploy/production-api-deploy.sh',
|
|
);
|
|
return spawnSync(
|
|
'bash',
|
|
[
|
|
deployScript,
|
|
'--source-dir',
|
|
fixture.sourceDir,
|
|
'--version',
|
|
options.version ?? fixture.version,
|
|
'--release-root',
|
|
options.releaseRoot ?? fixture.releaseRoot,
|
|
'--current-link',
|
|
options.currentLink ?? fixture.currentLink,
|
|
'--service',
|
|
'genarrative-api.service',
|
|
'--health-url',
|
|
'http://127.0.0.1:18082/readyz',
|
|
'--api-env-file',
|
|
options.apiEnvFile ?? fixture.apiEnvFile,
|
|
'--database',
|
|
'genarrative-prod',
|
|
'--spacetime-server-url',
|
|
'http://127.0.0.1:3101',
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${fixture.fakeBin}:${process.env.PATH || ''}`,
|
|
GENARRATIVE_MAINTENANCE_FILE: fixture.maintenanceFile,
|
|
FAKE_PINGORA_ACTIVE: options.pingoraActive === false ? 'false' : 'true',
|
|
FAKE_PINGORA_DIRECT_ENTRY:
|
|
options.pingoraDirectEntry === true ? 'true' : 'false',
|
|
FAKE_PINGORA_ENV_FILE: fixture.pingoraEnvFile,
|
|
FAKE_PINGORA_STATE_FILE: fixture.pingoraStateFile,
|
|
FAKE_CREATE_RELEASE_DURING_COPY:
|
|
options.createReleaseDuringCopy === true ? 'true' : 'false',
|
|
FAKE_RELEASE_ROOT: fixture.releaseRoot,
|
|
FAKE_RELEASE_VERSION: fixture.version,
|
|
FAKE_WORKER_STATE_FILE: fixture.workerStateFile,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function copyFile(source, target) {
|
|
writeFileSync(target, readFileSync(source, 'utf8'), 'utf8');
|
|
}
|
|
|
|
function writeSha256(directory, fileName) {
|
|
const result = spawnSync('sha256sum', [fileName], {
|
|
cwd: directory,
|
|
encoding: 'utf8',
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error(result.stderr || `sha256sum failed for ${fileName}`);
|
|
}
|
|
writeFileSync(
|
|
path.join(directory, `${fileName}.sha256`),
|
|
result.stdout,
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
function chmodExecutable(filePath) {
|
|
spawnSync('chmod', ['0755', filePath], {
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
});
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
}
|
|
|
|
function assertFileExists(filePath, reason) {
|
|
if (!existsSync(filePath)) {
|
|
failures.push(`${reason} 缺少: ${filePath}`);
|
|
}
|
|
}
|
|
|
|
function assertNoReleasePromoted(fixture, reason) {
|
|
const releaseDir = path.join(fixture.releaseRoot, fixture.version);
|
|
if (existsSync(releaseDir)) {
|
|
failures.push(`${reason} 已存在: ${releaseDir}`);
|
|
}
|
|
if (existsSync(fixture.currentLink)) {
|
|
failures.push(`${reason} 不应切换 current: ${fixture.currentLink}`);
|
|
}
|
|
}
|
|
|
|
function readDirNames(directory) {
|
|
return spawnSync('find', [directory, '-maxdepth', '1', '-mindepth', '1', '-printf', '%f\n'], {
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
})
|
|
.stdout.split('\n')
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function assertStatus(result, expected, reason) {
|
|
const actual = result.status ?? 0;
|
|
if (actual !== expected) {
|
|
failures.push(
|
|
`${reason} 预期退出码 ${expected},实际 ${actual}。\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertIncludes(content, needle, reason) {
|
|
if (!content.includes(needle)) {
|
|
failures.push(`${reason} 缺少: ${needle}`);
|
|
}
|
|
}
|
|
|
|
function assertPingoraEnvProductionDefaults(filePath, label) {
|
|
if (!existsSync(filePath)) {
|
|
return;
|
|
}
|
|
const content = readFileSync(filePath, 'utf8');
|
|
const requiredLines = [
|
|
[
|
|
'GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=gzip',
|
|
'压缩算法必须保持 gzip-only,避免 Brotli 在未验证前进入 current release。',
|
|
],
|
|
[
|
|
'GENARRATIVE_PINGORA_GATEWAY_TRUST_X_FORWARDED_FOR=false',
|
|
'公网直连默认不能信任客户端可伪造的 X-Forwarded-For。',
|
|
],
|
|
[
|
|
'GENARRATIVE_PINGORA_GATEWAY_TRUSTED_FRONT_PROXY_CONFIRMED=false',
|
|
'前置代理信任确认开关必须默认关闭。',
|
|
],
|
|
[
|
|
'GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED=true',
|
|
'接流保护必须默认开启。',
|
|
],
|
|
[
|
|
'GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN=',
|
|
'内部探针 token 示例必须保持空值,避免 current release 夹带真实 token。',
|
|
],
|
|
];
|
|
for (const [line, reason] of requiredLines) {
|
|
assertIncludes(content, line, `${label} ${reason}`);
|
|
}
|
|
}
|