243 行
9.9 KiB
JavaScript
可执行文件
243 行
9.9 KiB
JavaScript
可执行文件
#!/usr/bin/env node
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT_DIR = path.resolve(SCRIPT_DIR, "../..");
|
|
const HISTORICAL_TARGET_COMMIT = "34181e3";
|
|
const HISTORICAL_PROJECT_COMMIT = "5e2d038938265c926e06d3cc6d0841f6dcc06148";
|
|
const DEPLOY_SCRIPT_AT_COMMIT = "deploy/trialrun/deploy.sh";
|
|
const RUNBOOK_PATH = path.join(ROOT_DIR, "docs/trialrun/OPS7运维交接Runbook.md");
|
|
const REMOTE_BOOTSTRAP_SCRIPT = path.join(SCRIPT_DIR, "production-identity-bootstrap-remote.sh");
|
|
const DEPLOY_EXISTING_SCRIPT = path.join(SCRIPT_DIR, "deploy-existing.sh");
|
|
const DEFAULT_CONTRACT_UNIT = "hyb-oa-automatic-backup-daily.service";
|
|
const EXACT_REFRESH_PROBE = "[deployment-contract-refresh][PASS] probe";
|
|
const SAFE_SSH_TARGET_PATTERN = /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$/u;
|
|
const SAFE_ABSOLUTE_PATH_PATTERN = /^\/[A-Za-z0-9._/-]+$/u;
|
|
const SAFE_COMPOSE_FILE_PATTERN = /^[A-Za-z0-9._/-]+$/u;
|
|
const SAFE_PROJECT_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u;
|
|
|
|
export class ProductionIdentityBootstrapError extends Error {
|
|
constructor(code) {
|
|
super(code);
|
|
this.name = "ProductionIdentityBootstrapError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function fail(code) {
|
|
throw new ProductionIdentityBootstrapError(code);
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
return spawnSync(command, args, {
|
|
cwd: ROOT_DIR,
|
|
encoding: "utf8",
|
|
stdio: [options.input == null ? "ignore" : "pipe", "pipe", "pipe"],
|
|
input: options.input,
|
|
env: options.env ?? process.env,
|
|
});
|
|
}
|
|
|
|
function requiredCommandOutput(command, args, code) {
|
|
const result = run(command, args);
|
|
if (result.status !== 0 || !String(result.stdout ?? "").trim()) fail(code);
|
|
return String(result.stdout).trim();
|
|
}
|
|
|
|
function parseHistoricalDefault(source, key) {
|
|
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
const match = source.match(new RegExp(`^${escapedKey}="\\$\\{${escapedKey}:-([^}]+)\\}"$`, "mu"));
|
|
if (!match?.[1]) fail(`HISTORICAL_${key}_UNAVAILABLE`);
|
|
return match[1].trim();
|
|
}
|
|
|
|
export function parseHistoricalDeploymentIdentity(targetSource, projectSource) {
|
|
const deployServer = parseHistoricalDefault(targetSource, "DEPLOY_SERVER");
|
|
const remoteDir = parseHistoricalDefault(targetSource, "REMOTE_DIR");
|
|
const projectRemoteDir = parseHistoricalDefault(projectSource, "REMOTE_DIR");
|
|
const composeProjectName = parseHistoricalDefault(projectSource, "COMPOSE_PROJECT_NAME");
|
|
if (projectRemoteDir !== remoteDir) fail("HISTORICAL_REMOTE_DIR_DIVERGED");
|
|
if (!SAFE_SSH_TARGET_PATTERN.test(deployServer)) fail("HISTORICAL_DEPLOY_SERVER_INVALID");
|
|
if (!SAFE_ABSOLUTE_PATH_PATTERN.test(remoteDir)) fail("HISTORICAL_REMOTE_DIR_INVALID");
|
|
if (!SAFE_PROJECT_PATTERN.test(composeProjectName)) fail("HISTORICAL_COMPOSE_PROJECT_INVALID");
|
|
return { deployServer, remoteDir, composeProjectName };
|
|
}
|
|
|
|
export function parseExpectedProductionOrigin(runbook) {
|
|
const matches = [...String(runbook).matchAll(/curl\s+-fsS\s+(https:\/\/[^\s/]+)\/ready\b/gu)]
|
|
.map((match) => match[1]);
|
|
const unique = [...new Set(matches)];
|
|
if (unique.length !== 1) fail("EXPECTED_PRODUCTION_ORIGIN_NOT_UNIQUE");
|
|
return unique[0];
|
|
}
|
|
|
|
export function parseConcreteSshHosts(config) {
|
|
const hosts = [];
|
|
for (const match of String(config).matchAll(/^\s*Host\s+(.+)$/gmi)) {
|
|
for (const host of match[1].trim().split(/\s+/u)) {
|
|
if (host && !/[*!?]/u.test(host) && SAFE_SSH_TARGET_PATTERN.test(host)) hosts.push(host);
|
|
}
|
|
}
|
|
return [...new Set(hosts)];
|
|
}
|
|
|
|
export function selectUniqueRefreshTarget(probeResults) {
|
|
const matches = probeResults.filter((item) => item.status === 0 && item.stdout.trim() === EXACT_REFRESH_PROBE);
|
|
if (matches.length === 0) fail("REFRESH_IDENTITY_NOT_FOUND");
|
|
if (matches.length !== 1) fail("REFRESH_IDENTITY_NOT_UNIQUE");
|
|
return matches[0].target;
|
|
}
|
|
|
|
function discoverRefreshTarget() {
|
|
const sshConfigPath = path.join(homedir(), ".ssh", "config");
|
|
let config;
|
|
try {
|
|
config = readFileSync(sshConfigPath, "utf8");
|
|
} catch {
|
|
fail("SSH_CONFIG_UNREADABLE");
|
|
}
|
|
const candidates = parseConcreteSshHosts(config);
|
|
if (candidates.length === 0) fail("SSH_CONFIG_HAS_NO_CONCRETE_HOSTS");
|
|
const probeResults = candidates.map((target) => {
|
|
const result = run("ssh", [
|
|
"-o", "BatchMode=yes",
|
|
"-o", "ConnectTimeout=5",
|
|
target,
|
|
"probe",
|
|
]);
|
|
return { target, status: result.status, stdout: String(result.stdout ?? "") };
|
|
});
|
|
return selectUniqueRefreshTarget(probeResults);
|
|
}
|
|
|
|
export function parseDeploymentContract(raw) {
|
|
const allowed = new Set([
|
|
"REMOTE_DIR",
|
|
"REMOTE_ENV_FILE",
|
|
"COMPOSE_FILE",
|
|
"COMPOSE_PROJECT_NAME",
|
|
"POSTGRES_DATA_DIR",
|
|
"STORAGE_HOST_DIR",
|
|
]);
|
|
const contract = {};
|
|
for (const entry of String(raw).trim().split(/\s+/u)) {
|
|
const separator = entry.indexOf("=");
|
|
if (separator <= 0) continue;
|
|
const key = entry.slice(0, separator);
|
|
if (allowed.has(key)) contract[key] = entry.slice(separator + 1);
|
|
}
|
|
for (const key of allowed) {
|
|
if (!contract[key]) fail("SYSTEMD_CONTRACT_INCOMPLETE");
|
|
}
|
|
if (!SAFE_ABSOLUTE_PATH_PATTERN.test(contract.REMOTE_DIR)) fail("SYSTEMD_REMOTE_DIR_INVALID");
|
|
if (!SAFE_ABSOLUTE_PATH_PATTERN.test(contract.REMOTE_ENV_FILE)) fail("SYSTEMD_REMOTE_ENV_FILE_INVALID");
|
|
if (!SAFE_ABSOLUTE_PATH_PATTERN.test(contract.POSTGRES_DATA_DIR)) fail("SYSTEMD_POSTGRES_DATA_DIR_INVALID");
|
|
if (!SAFE_ABSOLUTE_PATH_PATTERN.test(contract.STORAGE_HOST_DIR)) fail("SYSTEMD_STORAGE_HOST_DIR_INVALID");
|
|
if (!SAFE_COMPOSE_FILE_PATTERN.test(contract.COMPOSE_FILE)) fail("SYSTEMD_COMPOSE_FILE_INVALID");
|
|
if (!SAFE_PROJECT_PATTERN.test(contract.COMPOSE_PROJECT_NAME)) fail("SYSTEMD_COMPOSE_PROJECT_INVALID");
|
|
return contract;
|
|
}
|
|
|
|
export function assertExactHistoricalContract(identity, contract) {
|
|
if (contract.REMOTE_DIR !== identity.remoteDir) fail("SYSTEMD_REMOTE_DIR_MISMATCH");
|
|
if (contract.REMOTE_ENV_FILE !== `${identity.remoteDir}/deploy/trialrun/.env`) {
|
|
fail("SYSTEMD_REMOTE_ENV_FILE_MISMATCH");
|
|
}
|
|
if (contract.COMPOSE_FILE !== "deploy/trialrun/docker-compose.yml") {
|
|
fail("SYSTEMD_COMPOSE_FILE_MISMATCH");
|
|
}
|
|
if (contract.COMPOSE_PROJECT_NAME !== identity.composeProjectName) {
|
|
fail("SYSTEMD_COMPOSE_PROJECT_MISMATCH");
|
|
}
|
|
}
|
|
|
|
function sha256Text(value) {
|
|
return createHash("sha256").update(String(value), "utf8").digest("hex");
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
}
|
|
|
|
function bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin) {
|
|
const remoteCommand = [
|
|
`REMOTE_DIR=${shellQuote(contract.REMOTE_DIR)}`,
|
|
`REMOTE_ENV_FILE=${shellQuote(contract.REMOTE_ENV_FILE)}`,
|
|
`COMPOSE_FILE=${shellQuote(contract.COMPOSE_FILE)}`,
|
|
`COMPOSE_PROJECT_NAME=${shellQuote(contract.COMPOSE_PROJECT_NAME)}`,
|
|
`EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`,
|
|
"bash -s",
|
|
].join(" ");
|
|
const remoteScript = readFileSync(REMOTE_BOOTSTRAP_SCRIPT, "utf8");
|
|
const result = run("ssh", [identity.deployServer, remoteCommand], { input: remoteScript });
|
|
if (result.status !== 0) {
|
|
const code = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.match(/code=([A-Z0-9_]+)/u)?.[1];
|
|
fail(code || "REMOTE_IDENTITY_BOOTSTRAP_FAILED");
|
|
}
|
|
const output = String(result.stdout ?? "").trim();
|
|
const match = output.match(/^\[production-identity-bootstrap\]\[DB_SHA256\] ([0-9a-f]{64})$/u);
|
|
if (!match) fail("REMOTE_DB_IDENTITY_PAYLOAD_INVALID");
|
|
return match[1];
|
|
}
|
|
|
|
function main() {
|
|
try {
|
|
const historicalTargetScript = requiredCommandOutput(
|
|
"git",
|
|
["show", `${HISTORICAL_TARGET_COMMIT}:${DEPLOY_SCRIPT_AT_COMMIT}`],
|
|
"HISTORICAL_IDENTITY_UNAVAILABLE",
|
|
);
|
|
const historicalProjectScript = requiredCommandOutput(
|
|
"git",
|
|
["show", `${HISTORICAL_PROJECT_COMMIT}:${DEPLOY_SCRIPT_AT_COMMIT}`],
|
|
"HISTORICAL_PROJECT_IDENTITY_UNAVAILABLE",
|
|
);
|
|
const identity = parseHistoricalDeploymentIdentity(historicalTargetScript, historicalProjectScript);
|
|
const expectedOrigin = parseExpectedProductionOrigin(readFileSync(RUNBOOK_PATH, "utf8"));
|
|
const refreshTarget = discoverRefreshTarget();
|
|
const contractRaw = requiredCommandOutput(
|
|
"ssh",
|
|
[identity.deployServer, `systemctl show '${DEFAULT_CONTRACT_UNIT}' -p Environment --value`],
|
|
"SYSTEMD_CONTRACT_UNAVAILABLE",
|
|
);
|
|
const contract = parseDeploymentContract(contractRaw);
|
|
assertExactHistoricalContract(identity, contract);
|
|
const databaseIdentityHash = bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin);
|
|
|
|
console.log("[production-identity-bootstrap][PASS] exact_identity_group");
|
|
const result = spawnSync("bash", [DEPLOY_EXISTING_SCRIPT], {
|
|
cwd: ROOT_DIR,
|
|
stdio: "inherit",
|
|
env: {
|
|
...process.env,
|
|
DEPLOY_SERVER: identity.deployServer,
|
|
DEPLOY_REFRESH_SERVER: refreshTarget,
|
|
EXPECTED_DEPLOY_SERVER: identity.deployServer,
|
|
EXPECTED_REMOTE_DIR: identity.remoteDir,
|
|
EXPECTED_COMPOSE_PROJECT_NAME: identity.composeProjectName,
|
|
EXPECTED_PRODUCTION_ORIGIN: expectedOrigin,
|
|
EXPECTED_PRODUCTION_DB_NAME_SHA256: databaseIdentityHash,
|
|
EXPECTED_PENDING_MIGRATIONS: process.env.EXPECTED_PENDING_MIGRATIONS ?? "",
|
|
PRODUCTION_READ_ONLY_PREFLIGHT: process.env.PRODUCTION_READ_ONLY_PREFLIGHT ?? "0",
|
|
},
|
|
});
|
|
if (result.status !== 0) fail("DEPLOY_EXISTING_FAILED");
|
|
} catch (error) {
|
|
const code = error instanceof ProductionIdentityBootstrapError
|
|
? error.code
|
|
: "PRODUCTION_IDENTITY_BOOTSTRAP_UNEXPECTED_ERROR";
|
|
console.error(`[production-identity-bootstrap][PAUSED] code=${code}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
if (isMain) main();
|