merge: 集成V2.8 生产硬化与运维闭环
# Conflicts: # .gitignore # docs/deployment.md # docs/roadmap.md # package.json
This commit is contained in:
139
scripts/backup-postgres.mjs
Normal file
139
scripts/backup-postgres.mjs
Normal file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
import { createWriteStream, unlinkSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
commandToString,
|
||||
composePrefix,
|
||||
ensureParentDir,
|
||||
flag,
|
||||
option,
|
||||
parseArgs,
|
||||
readEnvFile,
|
||||
timestampSlug,
|
||||
} from './ops-utils.mjs';
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/backup-postgres.mjs [options]
|
||||
|
||||
Options:
|
||||
--env-file <path> Compose env file (default: .env.production)
|
||||
--compose-file <path> Compose file (default: docker-compose.prod.yml)
|
||||
--service <name> Postgres service name (default: postgres)
|
||||
--user <name> Database user (default: POSTGRES_USER or postgres)
|
||||
--db <name> Database name (default: POSTGRES_DB or ftb_pm)
|
||||
--backup-dir <path> Directory for generated backups (default: backups/postgres)
|
||||
--output <path> Exact output dump path
|
||||
--dry-run Print the pg_dump command without writing a file
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function buildOptions(argv) {
|
||||
const args = parseArgs(argv);
|
||||
if (flag(args, 'help')) return { help: true };
|
||||
|
||||
const envFile = option(args, 'env-file', '.env.production');
|
||||
const env = readEnvFile(envFile, { optional: flag(args, 'dry-run') });
|
||||
const db = option(args, 'db', env.POSTGRES_DB || 'ftb_pm');
|
||||
const user = option(args, 'user', env.POSTGRES_USER || 'postgres');
|
||||
const backupDir = option(args, 'backup-dir', 'backups/postgres');
|
||||
const output = option(
|
||||
args,
|
||||
'output',
|
||||
join(backupDir, `${db}-postgres-${timestampSlug()}.dump`),
|
||||
);
|
||||
|
||||
return {
|
||||
help: false,
|
||||
dryRun: flag(args, 'dry-run'),
|
||||
envFile,
|
||||
composeFile: option(args, 'compose-file', 'docker-compose.prod.yml'),
|
||||
service: option(args, 'service', 'postgres'),
|
||||
db,
|
||||
user,
|
||||
output: resolve(output),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPgDumpCommand(options) {
|
||||
return [
|
||||
...composePrefix(options),
|
||||
'exec',
|
||||
'-T',
|
||||
options.service,
|
||||
'pg_dump',
|
||||
'-U',
|
||||
options.user,
|
||||
'-d',
|
||||
options.db,
|
||||
'--format=custom',
|
||||
'--no-owner',
|
||||
'--no-acl',
|
||||
];
|
||||
}
|
||||
|
||||
async function writeBackup(command, output) {
|
||||
ensureParentDir(output);
|
||||
await new Promise((resolveWrite, rejectWrite) => {
|
||||
const file = createWriteStream(output, { flags: 'wx' });
|
||||
const child = spawn(command[0], command.slice(1), {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const rejectOnce = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
unlinkSync(output);
|
||||
} catch {
|
||||
// Best effort cleanup of a partial dump.
|
||||
}
|
||||
rejectWrite(error);
|
||||
};
|
||||
|
||||
child.stdout.pipe(file);
|
||||
child.on('error', rejectOnce);
|
||||
file.on('error', rejectOnce);
|
||||
child.on('close', (code) => {
|
||||
file.end(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (code === 0) {
|
||||
resolveWrite();
|
||||
} else {
|
||||
rejectOnce(new Error(`pg_dump failed with exit code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const options = buildOptions(argv);
|
||||
if (options.help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const command = buildPgDumpCommand(options);
|
||||
if (options.dryRun) {
|
||||
process.stdout.write(`[dry-run] PostgreSQL backup would write: ${options.output}\n`);
|
||||
process.stdout.write(`${commandToString(command, { stdout: options.output })}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
await writeBackup(command, options.output);
|
||||
process.stdout.write(`PostgreSQL backup written: ${options.output}\n`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
99
scripts/backup-server-data.mjs
Normal file
99
scripts/backup-server-data.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
commandToString,
|
||||
ensureParentDir,
|
||||
flag,
|
||||
option,
|
||||
parseArgs,
|
||||
readEnvFile,
|
||||
runCommand,
|
||||
timestampSlug,
|
||||
} from './ops-utils.mjs';
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/backup-server-data.mjs [options]
|
||||
|
||||
Backs up the server_data Docker volume that stores runtime AI provider config.
|
||||
|
||||
Options:
|
||||
--env-file <path> Compose env file used to derive COMPOSE_PROJECT_NAME
|
||||
(default: .env.production)
|
||||
--volume <name> Explicit Docker volume name
|
||||
--backup-dir <path> Directory for generated backups (default: backups/server-data)
|
||||
--output <path> Exact output .tgz path
|
||||
--image <name> Utility image (default: alpine:3.20)
|
||||
--dry-run Print the docker run command without writing a file
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function buildOptions(argv) {
|
||||
const args = parseArgs(argv);
|
||||
if (flag(args, 'help')) return { help: true };
|
||||
|
||||
const envFile = option(args, 'env-file', '.env.production');
|
||||
const env = readEnvFile(envFile, { optional: flag(args, 'dry-run') });
|
||||
const projectName = env.COMPOSE_PROJECT_NAME || 'ftb_pm';
|
||||
const volume = option(args, 'volume', `${projectName}_server_data`);
|
||||
const backupDir = option(args, 'backup-dir', 'backups/server-data');
|
||||
const output = option(
|
||||
args,
|
||||
'output',
|
||||
join(backupDir, `${volume}-${timestampSlug()}.tgz`),
|
||||
);
|
||||
|
||||
return {
|
||||
help: false,
|
||||
dryRun: flag(args, 'dry-run'),
|
||||
image: option(args, 'image', 'alpine:3.20'),
|
||||
volume,
|
||||
output: resolve(output),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildServerDataBackupCommand(options) {
|
||||
const outputDir = dirname(options.output);
|
||||
const outputName = basename(options.output);
|
||||
return [
|
||||
'docker',
|
||||
'run',
|
||||
'--rm',
|
||||
'-v',
|
||||
`${options.volume}:/data:ro`,
|
||||
'-v',
|
||||
`${outputDir}:/backup`,
|
||||
options.image,
|
||||
'sh',
|
||||
'-lc',
|
||||
`tar -czf /backup/${outputName} -C /data .`,
|
||||
];
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const options = buildOptions(argv);
|
||||
if (options.help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const command = buildServerDataBackupCommand(options);
|
||||
if (options.dryRun) {
|
||||
process.stdout.write(`[dry-run] server_data backup would write: ${options.output}\n`);
|
||||
process.stdout.write(`${commandToString(command)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureParentDir(options.output);
|
||||
await runCommand(command);
|
||||
process.stdout.write(`server_data backup written: ${options.output}\n`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
87
scripts/check-runbook-placeholders.mjs
Normal file
87
scripts/check-runbook-placeholders.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { option, parseArgs } from './ops-utils.mjs';
|
||||
|
||||
const forbiddenPatterns = [
|
||||
{ name: 'TBD', pattern: /\bTBD\b/i },
|
||||
{ name: 'TODO', pattern: /\bTODO\b/i },
|
||||
{ name: 'fill-in', pattern: /fill in|fill-in/i },
|
||||
{ name: 'angle-token', pattern: /<[^>\n]+>/ },
|
||||
{ name: 'Chinese pending marker', pattern: /待定|占位/ },
|
||||
];
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/check-runbook-placeholders.mjs --paths <path> [path...]
|
||||
|
||||
Scans Markdown runbooks for unresolved placeholder markers.
|
||||
`;
|
||||
}
|
||||
|
||||
function collectMarkdownFiles(path) {
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`Path not found: ${path}`);
|
||||
}
|
||||
|
||||
const stat = statSync(path);
|
||||
if (stat.isFile()) return extname(path) === '.md' ? [path] : [];
|
||||
if (!stat.isDirectory()) return [];
|
||||
|
||||
const files = [];
|
||||
for (const entry of readdirSync(path)) {
|
||||
files.push(...collectMarkdownFiles(join(path, entry)));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function requestedPaths(argv) {
|
||||
const args = parseArgs(argv);
|
||||
if (args.flags.has('help')) return { help: true, paths: [] };
|
||||
const first = option(args, 'paths');
|
||||
const paths = [first, ...args.positionals].filter(Boolean);
|
||||
if (paths.length === 0) {
|
||||
throw new Error('Missing --paths <path> [path...]');
|
||||
}
|
||||
return { help: false, paths };
|
||||
}
|
||||
|
||||
export function scanFiles(paths) {
|
||||
const files = paths.flatMap(collectMarkdownFiles);
|
||||
const findings = [];
|
||||
for (const file of files) {
|
||||
const lines = readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
lines.forEach((line, index) => {
|
||||
for (const forbidden of forbiddenPatterns) {
|
||||
if (forbidden.pattern.test(line)) {
|
||||
findings.push(`${file}:${index + 1} ${forbidden.name}: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return { files, findings };
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const { help, paths } = requestedPaths(argv);
|
||||
if (help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const { files, findings } = scanFiles(paths);
|
||||
if (findings.length > 0) {
|
||||
process.stderr.write(`${findings.join('\n')}\n`);
|
||||
throw new Error(`Runbook placeholder scan failed: ${findings.length} finding(s)`);
|
||||
}
|
||||
|
||||
process.stdout.write(`Runbook placeholder scan passed (${files.length} file(s)).\n`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
152
scripts/ops-scripts.test.mjs
Normal file
152
scripts/ops-scripts.test.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
function runScript(script, args) {
|
||||
return spawnSync(process.execPath, [join(root, script), ...args], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
describe('production ops scripts', () => {
|
||||
it('prints a pg_dump command in dry-run mode without writing the target file', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ftb-pg-backup-'));
|
||||
const output = join(dir, 'backup.dump');
|
||||
|
||||
const result = runScript('scripts/backup-postgres.mjs', [
|
||||
'--dry-run',
|
||||
'--env-file',
|
||||
'.env.production.example',
|
||||
'--output',
|
||||
output,
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /docker compose/);
|
||||
assert.match(result.stdout, /pg_dump/);
|
||||
assert.match(result.stdout, /backup\.dump/);
|
||||
assert.equal(existsSync(output), false);
|
||||
});
|
||||
|
||||
it('refuses restore by default unless --confirm-overwrite is supplied', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ftb-pg-restore-'));
|
||||
const input = join(dir, 'backup.dump');
|
||||
writeFileSync(input, 'not-a-real-dump');
|
||||
|
||||
const result = runScript('scripts/restore-postgres.mjs', [
|
||||
'--env-file',
|
||||
'.env.production.example',
|
||||
'--input',
|
||||
input,
|
||||
]);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /--confirm-overwrite/);
|
||||
});
|
||||
|
||||
it('prints fresh database restore steps in dry-run mode after explicit overwrite confirmation', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ftb-pg-restore-dry-'));
|
||||
const input = join(dir, 'backup.dump');
|
||||
writeFileSync(input, 'not-a-real-dump');
|
||||
|
||||
const result = runScript('scripts/restore-postgres.mjs', [
|
||||
'--dry-run',
|
||||
'--confirm-overwrite',
|
||||
'--env-file',
|
||||
'.env.production.example',
|
||||
'--input',
|
||||
input,
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /dropdb/);
|
||||
assert.match(result.stdout, /createdb/);
|
||||
assert.match(result.stdout, /pg_restore/);
|
||||
});
|
||||
|
||||
it('prints a server_data volume tar backup command in dry-run mode', () => {
|
||||
const result = runScript('scripts/backup-server-data.mjs', [
|
||||
'--dry-run',
|
||||
'--env-file',
|
||||
'.env.production.example',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /docker run/);
|
||||
assert.match(result.stdout, /ftb_pm_server_data/);
|
||||
assert.match(result.stdout, /tar -czf/);
|
||||
});
|
||||
|
||||
it('prints read-only release smoke checks in dry-run mode', () => {
|
||||
const result = runScript('scripts/smoke-test-release.mjs', [
|
||||
'--dry-run',
|
||||
'--base-url',
|
||||
'http://localhost',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /\/api\/v1\/health\/version/);
|
||||
assert.match(result.stdout, /\/products/);
|
||||
assert.match(result.stdout, /\/api\/v1\/products/);
|
||||
assert.match(result.stdout, /\/api\/v1\/v2\.2\/requirements\?productId=__smoke__/);
|
||||
assert.match(result.stdout, /\/api\/v1\/config\/ai/);
|
||||
});
|
||||
|
||||
it('declares the production monitoring baseline without real alert secrets', () => {
|
||||
const alertRules = readFileSync(
|
||||
join(root, 'deploy/monitoring/prometheus/alert-rules.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const postgresQueries = readFileSync(
|
||||
join(root, 'deploy/monitoring/postgres/postgres-queries.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const promtailConfig = readFileSync(
|
||||
join(root, 'deploy/monitoring/promtail/config.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const dashboard = readFileSync(
|
||||
join(root, 'deploy/monitoring/grafana/dashboards/ftb-production-overview.json'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
for (const alertName of [
|
||||
'FtbPostgresDown',
|
||||
'FtbDiskPressure',
|
||||
'FtbSlowApiLogBurst',
|
||||
'FtbSlowPrismaLogBurst',
|
||||
'FtbJobFailureLogBurst',
|
||||
'FtbXiaobaoSummaryStale',
|
||||
]) {
|
||||
assert.match(alertRules, new RegExp(alertName));
|
||||
}
|
||||
|
||||
assert.match(postgresQueries, /xiaobao_risk_summaries/);
|
||||
assert.match(postgresQueries, /dirty = true/);
|
||||
assert.match(promtailConfig, /Slow API request/);
|
||||
assert.match(promtailConfig, /Slow Prisma query/);
|
||||
assert.match(promtailConfig, /AppData relation sync failed/);
|
||||
assert.match(dashboard, /FTB Production Overview/);
|
||||
|
||||
const combined = `${alertRules}\n${postgresQueries}\n${promtailConfig}\n${dashboard}`;
|
||||
assert.doesNotMatch(combined, /sk-ant-[A-Za-z0-9]/);
|
||||
assert.doesNotMatch(combined, /hooks\.slack\.com\/services\//);
|
||||
});
|
||||
|
||||
it('passes the runbook placeholder scan', () => {
|
||||
const result = runScript('scripts/check-runbook-placeholders.mjs', [
|
||||
'--paths',
|
||||
'docs/runbooks',
|
||||
'docs/production-readiness.md',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Runbook placeholder scan passed/);
|
||||
});
|
||||
});
|
||||
116
scripts/ops-utils.mjs
Normal file
116
scripts/ops-utils.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const parsed = { flags: new Set(), values: new Map(), positionals: [] };
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--') continue;
|
||||
if (!arg.startsWith('--')) {
|
||||
parsed.positionals.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = arg.indexOf('=');
|
||||
if (eq !== -1) {
|
||||
parsed.values.set(arg.slice(2, eq), arg.slice(eq + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = arg.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
parsed.values.set(key, next);
|
||||
index += 1;
|
||||
} else {
|
||||
parsed.flags.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function option(args, name, fallback = undefined) {
|
||||
return args.values.has(name) ? args.values.get(name) : fallback;
|
||||
}
|
||||
|
||||
export function flag(args, name) {
|
||||
return args.flags.has(name);
|
||||
}
|
||||
|
||||
export function readEnvFile(filePath, { optional = false } = {}) {
|
||||
if (!existsSync(filePath)) {
|
||||
if (optional) return {};
|
||||
throw new Error(`Env file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const env = {};
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const eq = line.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function ensureParentDir(filePath) {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
export function timestampSlug(date = new Date()) {
|
||||
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
export function shellQuote(value) {
|
||||
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export function commandToString(command, { stdin, stdout } = {}) {
|
||||
const rendered = command.map((part) => shellQuote(part)).join(' ');
|
||||
const withStdin = stdin ? `${rendered} < ${shellQuote(stdin)}` : rendered;
|
||||
return stdout ? `${withStdin} > ${shellQuote(stdout)}` : withStdin;
|
||||
}
|
||||
|
||||
export function composePrefix({ envFile, composeFile }) {
|
||||
return ['docker', 'compose', '--env-file', envFile, '-f', composeFile];
|
||||
}
|
||||
|
||||
export function resolvePath(path) {
|
||||
return resolve(process.cwd(), path);
|
||||
}
|
||||
|
||||
export function runCommand(command, options = {}) {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const child = spawn(command[0], command.slice(1), {
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
});
|
||||
|
||||
child.on('error', rejectRun);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolveRun();
|
||||
} else {
|
||||
rejectRun(new Error(`Command failed (${code}): ${commandToString(command)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function quotePgLiteral(value) {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
181
scripts/restore-postgres.mjs
Normal file
181
scripts/restore-postgres.mjs
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
commandToString,
|
||||
composePrefix,
|
||||
flag,
|
||||
option,
|
||||
parseArgs,
|
||||
quotePgLiteral,
|
||||
readEnvFile,
|
||||
runCommand,
|
||||
} from './ops-utils.mjs';
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/restore-postgres.mjs --input <dump> --confirm-overwrite [options]
|
||||
|
||||
This script restores into a freshly recreated PostgreSQL database. It refuses to
|
||||
drop/recreate a database unless --confirm-overwrite is provided.
|
||||
|
||||
Options:
|
||||
--input <path> pg_dump custom-format dump file
|
||||
--confirm-overwrite Required safety flag for destructive restore
|
||||
--env-file <path> Compose env file (default: .env.production)
|
||||
--compose-file <path> Compose file (default: docker-compose.prod.yml)
|
||||
--service <name> Postgres service name (default: postgres)
|
||||
--user <name> Database user (default: POSTGRES_USER or postgres)
|
||||
--target-db <name> Target database (default: POSTGRES_DB or ftb_pm)
|
||||
--maintenance-db <name> Maintenance database (default: postgres)
|
||||
--dry-run Print restore commands without changing data
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function buildOptions(argv) {
|
||||
const args = parseArgs(argv);
|
||||
if (flag(args, 'help')) return { help: true };
|
||||
|
||||
const envFile = option(args, 'env-file', '.env.production');
|
||||
const env = readEnvFile(envFile, { optional: flag(args, 'dry-run') });
|
||||
const input = option(args, 'input');
|
||||
if (!input) {
|
||||
throw new Error('Missing required --input <dump>');
|
||||
}
|
||||
|
||||
const inputPath = resolve(input);
|
||||
if (!existsSync(inputPath)) {
|
||||
throw new Error(`Restore input not found: ${inputPath}`);
|
||||
}
|
||||
|
||||
if (!flag(args, 'confirm-overwrite')) {
|
||||
throw new Error('Refusing destructive restore. Re-run with --confirm-overwrite.');
|
||||
}
|
||||
|
||||
return {
|
||||
help: false,
|
||||
dryRun: flag(args, 'dry-run'),
|
||||
input: inputPath,
|
||||
envFile,
|
||||
composeFile: option(args, 'compose-file', 'docker-compose.prod.yml'),
|
||||
service: option(args, 'service', 'postgres'),
|
||||
user: option(args, 'user', env.POSTGRES_USER || 'postgres'),
|
||||
targetDb: option(args, 'target-db', env.POSTGRES_DB || 'ftb_pm'),
|
||||
maintenanceDb: option(args, 'maintenance-db', 'postgres'),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRestoreCommands(options) {
|
||||
const prefix = composePrefix(options);
|
||||
const terminateSql = [
|
||||
'SELECT pg_terminate_backend(pid)',
|
||||
'FROM pg_stat_activity',
|
||||
`WHERE datname = ${quotePgLiteral(options.targetDb)} AND pid <> pg_backend_pid();`,
|
||||
].join(' ');
|
||||
|
||||
return [
|
||||
[
|
||||
...prefix,
|
||||
'exec',
|
||||
'-T',
|
||||
options.service,
|
||||
'psql',
|
||||
'-U',
|
||||
options.user,
|
||||
'-d',
|
||||
options.maintenanceDb,
|
||||
'-v',
|
||||
'ON_ERROR_STOP=1',
|
||||
'-c',
|
||||
terminateSql,
|
||||
],
|
||||
[
|
||||
...prefix,
|
||||
'exec',
|
||||
'-T',
|
||||
options.service,
|
||||
'dropdb',
|
||||
'--if-exists',
|
||||
'-U',
|
||||
options.user,
|
||||
options.targetDb,
|
||||
],
|
||||
[
|
||||
...prefix,
|
||||
'exec',
|
||||
'-T',
|
||||
options.service,
|
||||
'createdb',
|
||||
'-U',
|
||||
options.user,
|
||||
options.targetDb,
|
||||
],
|
||||
[
|
||||
...prefix,
|
||||
'exec',
|
||||
'-T',
|
||||
options.service,
|
||||
'pg_restore',
|
||||
'-U',
|
||||
options.user,
|
||||
'-d',
|
||||
options.targetDb,
|
||||
'--no-owner',
|
||||
'--no-acl',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function runRestore(command, input) {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const child = spawn(command[0], command.slice(1), {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
createReadStream(input).pipe(child.stdin);
|
||||
child.on('error', rejectRun);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolveRun();
|
||||
} else {
|
||||
rejectRun(new Error(`pg_restore failed with exit code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const options = buildOptions(argv);
|
||||
if (options.help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = buildRestoreCommands(options);
|
||||
if (options.dryRun) {
|
||||
process.stdout.write(
|
||||
`[dry-run] PostgreSQL restore would recreate database: ${options.targetDb}\n`,
|
||||
);
|
||||
for (const command of commands.slice(0, -1)) {
|
||||
process.stdout.write(`${commandToString(command)}\n`);
|
||||
}
|
||||
process.stdout.write(`${commandToString(commands.at(-1), { stdin: options.input })}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const command of commands.slice(0, -1)) {
|
||||
await runCommand(command);
|
||||
}
|
||||
await runRestore(commands.at(-1), options.input);
|
||||
process.stdout.write(`PostgreSQL restore completed into fresh database: ${options.targetDb}\n`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
160
scripts/smoke-test-release.mjs
Normal file
160
scripts/smoke-test-release.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { flag, option, parseArgs } from './ops-utils.mjs';
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/smoke-test-release.mjs --base-url <url> [options]
|
||||
|
||||
Read-only release smoke checks:
|
||||
- backend runtime version
|
||||
- frontend root page
|
||||
- frontend products route
|
||||
- products API
|
||||
- V2.2 requirement read path
|
||||
- AI config public endpoint
|
||||
|
||||
Options:
|
||||
--base-url <url> Deployment base URL (default: http://127.0.0.1)
|
||||
--expected-version <sha> Expected /api/v1/health/version payload version
|
||||
--timeout-ms <number> Per-request timeout (default: 5000)
|
||||
--dry-run Print planned checks without making requests
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
const url = new URL(value || 'http://127.0.0.1');
|
||||
url.pathname = url.pathname.replace(/\/+$/, '');
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function buildSmokeChecks(baseUrl, expectedVersion = '') {
|
||||
const checks = [
|
||||
{
|
||||
name: 'backend runtime version',
|
||||
path: '/api/v1/health/version',
|
||||
kind: 'json',
|
||||
validate(payload) {
|
||||
if (payload?.service !== 'server' || typeof payload.version !== 'string') {
|
||||
throw new Error(`unexpected version payload: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
if (expectedVersion && payload.version !== expectedVersion) {
|
||||
throw new Error(`expected version ${expectedVersion}, got ${payload.version}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: 'frontend root', path: '/', kind: 'text' },
|
||||
{ name: 'frontend products route', path: '/products', kind: 'text' },
|
||||
{
|
||||
name: 'products API',
|
||||
path: '/api/v1/products',
|
||||
kind: 'json',
|
||||
validate(payload) {
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error('products API did not return an array');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'V2.2 requirement scoped read',
|
||||
path: '/api/v1/v2.2/requirements?productId=__smoke__&limit=1',
|
||||
kind: 'json',
|
||||
validate(payload) {
|
||||
if (!payload || !Array.isArray(payload.items)) {
|
||||
throw new Error('V2.2 requirements response missing items array');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AI config public endpoint',
|
||||
path: '/api/v1/config/ai',
|
||||
kind: 'json',
|
||||
validate(payload) {
|
||||
if (!payload || !Array.isArray(payload.providers)) {
|
||||
throw new Error('AI config response missing providers array');
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return checks.map((check) => ({
|
||||
...check,
|
||||
url: new URL(check.path, `${baseUrl}/`).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCheck(check, timeoutMs) {
|
||||
const response = await fetchWithTimeout(check.url, timeoutMs);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (check.kind === 'json') {
|
||||
const payload = await response.json();
|
||||
check.validate?.(payload);
|
||||
} else {
|
||||
await response.text();
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const args = parseArgs(argv);
|
||||
if (flag(args, 'help')) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = normalizeBaseUrl(option(args, 'base-url', 'http://127.0.0.1'));
|
||||
const expectedVersion = option(args, 'expected-version', '');
|
||||
const timeoutMs = Number.parseInt(option(args, 'timeout-ms', '5000'), 10);
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new Error('--timeout-ms must be a positive number');
|
||||
}
|
||||
|
||||
const checks = buildSmokeChecks(baseUrl, expectedVersion);
|
||||
if (flag(args, 'dry-run')) {
|
||||
process.stdout.write(`[dry-run] Release smoke target: ${baseUrl}\n`);
|
||||
for (const check of checks) {
|
||||
process.stdout.write(`GET ${check.url} # ${check.name}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`Release smoke target: ${baseUrl}\n`);
|
||||
const failures = [];
|
||||
for (const check of checks) {
|
||||
try {
|
||||
await runCheck(check, timeoutMs);
|
||||
process.stdout.write(`[pass] ${check.name} ${check.url}\n`);
|
||||
} catch (error) {
|
||||
failures.push(`[fail] ${check.name} ${check.url}: ${error.message}`);
|
||||
process.stderr.write(`${failures.at(-1)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Release smoke failed: ${failures.length} check(s) failed`);
|
||||
}
|
||||
|
||||
process.stdout.write('Release smoke test passed.\n');
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const checks = [
|
||||
'NEXT_PUBLIC_API_URL',
|
||||
'NEXT_PUBLIC_APP_VERSION',
|
||||
'ARG APP_VERSION=unknown',
|
||||
'COPY scripts ./scripts',
|
||||
'CMD ["pnpm", "--filter", "web", "start"]',
|
||||
],
|
||||
},
|
||||
@@ -50,6 +51,11 @@ const checks = [
|
||||
'WEB_IMAGE',
|
||||
'APP_VERSION',
|
||||
'/api/v1/health/version',
|
||||
'prometheus:',
|
||||
"profiles: ['monitoring']",
|
||||
'postgres-exporter:',
|
||||
'blackbox-exporter:',
|
||||
'prometheus_data:',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -59,13 +65,42 @@ const checks = [
|
||||
'appleboy/ssh-action',
|
||||
'docker compose --env-file .env.production -f docker-compose.prod.yml pull',
|
||||
'pnpm --filter server db:deploy',
|
||||
'/api/v1/health/version',
|
||||
'scripts/smoke-test-release.mjs',
|
||||
'--expected-version',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'scripts/check-runtime-version.mjs',
|
||||
snippets: ['Runtime version verified', '/api/v1/health/version', 'expectedVersion'],
|
||||
},
|
||||
{
|
||||
file: 'scripts/smoke-test-release.mjs',
|
||||
snippets: [
|
||||
'Release smoke test passed',
|
||||
'/api/v1/v2.2/requirements?productId=__smoke__',
|
||||
'/api/v1/config/ai',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'scripts/backup-postgres.mjs',
|
||||
snippets: ['pg_dump', '--format=custom', '--dry-run'],
|
||||
},
|
||||
{
|
||||
file: 'scripts/restore-postgres.mjs',
|
||||
snippets: ['--confirm-overwrite', 'dropdb', 'pg_restore'],
|
||||
},
|
||||
{
|
||||
file: 'scripts/backup-server-data.mjs',
|
||||
snippets: ['server_data', 'tar -czf', '--dry-run'],
|
||||
},
|
||||
{
|
||||
file: 'scripts/check-runbook-placeholders.mjs',
|
||||
snippets: ['Runbook placeholder scan passed', 'Runbook placeholder scan failed', '--paths'],
|
||||
},
|
||||
{
|
||||
file: 'package.json',
|
||||
snippets: ['docs:check-runbooks', 'docs/runbooks', 'docs/production-readiness.md'],
|
||||
},
|
||||
{
|
||||
file: 'docker-compose.local.yml',
|
||||
snippets: [
|
||||
@@ -88,6 +123,66 @@ const checks = [
|
||||
'X-Forwarded-Proto',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'deploy/monitoring/README.md',
|
||||
snippets: [
|
||||
'--profile monitoring',
|
||||
'FtbPostgresDown',
|
||||
'FtbXiaobaoSummaryStale',
|
||||
'no webhook URLs',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'deploy/monitoring/prometheus/prometheus.yml',
|
||||
snippets: ['postgres-exporter:9187', 'promtail:9080', 'blackbox-http'],
|
||||
},
|
||||
{
|
||||
file: 'deploy/monitoring/prometheus/alert-rules.yml',
|
||||
snippets: [
|
||||
'FtbPostgresDown',
|
||||
'FtbSlowApiLogBurst',
|
||||
'FtbSlowPrismaLogBurst',
|
||||
'FtbJobFailureLogBurst',
|
||||
'FtbXiaobaoSummaryStale',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'docs/runbooks/migration-rollback.md',
|
||||
snippets: ['Fresh Database Restore', 'Data Risks', 'pnpm restore:postgres'],
|
||||
},
|
||||
{
|
||||
file: 'docs/runbooks/appdata-retirement.md',
|
||||
snippets: ['Disable AppData Writes', 'Remove Fallback', 'Data Risks'],
|
||||
},
|
||||
{
|
||||
file: 'docs/runbooks/xiaobao-background-jobs.md',
|
||||
snippets: ['FtbXiaobaoSummaryStale', 'Future Scheduler Rules', 'Data Risks'],
|
||||
},
|
||||
{
|
||||
file: 'docs/production-readiness.md',
|
||||
snippets: [
|
||||
'Backup and restore',
|
||||
'Smoke tests',
|
||||
'Monitoring',
|
||||
'Audit',
|
||||
'RBAC',
|
||||
'Consistency',
|
||||
'Performance',
|
||||
'Post-release',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'deploy/monitoring/postgres/postgres-queries.yml',
|
||||
snippets: ['xiaobao_risk_summaries', 'dirty = true', 'stale_summary_count'],
|
||||
},
|
||||
{
|
||||
file: 'deploy/monitoring/promtail/config.yml',
|
||||
snippets: ['Slow API request', 'Slow Prisma query', 'AppData relation sync failed'],
|
||||
},
|
||||
{
|
||||
file: 'deploy/monitoring/grafana/dashboards/ftb-production-overview.json',
|
||||
snippets: ['FTB Production Overview', 'ftb_xiaobao_stale_summary_count', 'Server Warning/Error Logs'],
|
||||
},
|
||||
{
|
||||
file: '.env.production.example',
|
||||
snippets: [
|
||||
@@ -118,6 +213,12 @@ const checks = [
|
||||
'docker-compose.local.yml',
|
||||
'本地服务器部署',
|
||||
'pnpm deploy:verify',
|
||||
'pnpm backup:postgres',
|
||||
'pnpm restore:postgres',
|
||||
'pnpm backup:server-data',
|
||||
'docs/runbooks/migration-rollback.md',
|
||||
'docs/production-readiness.md',
|
||||
'--profile monitoring',
|
||||
'pnpm db:migrate',
|
||||
'NEXT_PUBLIC_API_URL',
|
||||
'Nginx',
|
||||
|
||||
Reference in New Issue
Block a user