文件
hyb-oa/deploy/trialrun/production-identity-bootstrap.mjs
T

265 行
11 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";
import { deploymentReadOnlyMode, requireReleaseReceipt, ReleaseGateError } from "../../scripts/qa/release-gate-result.mjs";
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 composeProjectName = parseHistoricalDefault(projectSource, "COMPOSE_PROJECT_NAME");
if (!SAFE_SSH_TARGET_PATTERN.test(deployServer)) fail("HISTORICAL_DEPLOY_SERVER_INVALID");
if (!SAFE_PROJECT_PATTERN.test(composeProjectName)) fail("HISTORICAL_COMPOSE_PROJECT_INVALID");
return { deployServer, 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 assertContractForOriginVerification(identity, contract) {
if (contract.REMOTE_ENV_FILE !== `${contract.REMOTE_DIR}/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 runRemoteIdentityBootstrap(mode, identity, contract, expectedOrigin) {
const remoteCommand = [
`IDENTITY_BOOTSTRAP_MODE=${shellQuote(mode)}`,
`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");
}
return String(result.stdout ?? "").trim();
}
function verifyOriginIdentity(identity, contract, expectedOrigin) {
const output = runRemoteIdentityBootstrap("origin", identity, contract, expectedOrigin);
if (output !== "[production-identity-bootstrap][PASS] origin_identity") {
fail("REMOTE_ORIGIN_IDENTITY_PAYLOAD_INVALID");
}
}
function bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin) {
const output = runRemoteIdentityBootstrap("database", identity, contract, expectedOrigin);
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 identityOnly = process.env.PRODUCTION_IDENTITY_ONLY ?? "0";
if (!["0", "1"].includes(identityOnly)) fail("PRODUCTION_IDENTITY_ONLY_INVALID");
const { preflightOnly } = deploymentReadOnlyMode(process.env, true);
// Reject before SSH configuration discovery, target probes or database reads.
const release = identityOnly === "1" || preflightOnly ? null : requireReleaseReceipt();
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);
assertContractForOriginVerification(identity, contract);
verifyOriginIdentity(identity, contract, expectedOrigin);
const expectedRemoteDir = contract.REMOTE_DIR;
if (identityOnly === "1") {
console.log("[production-identity-bootstrap][PASS] origin_verified_remote_dir_baseline=systemd_contract");
return;
}
const databaseIdentityHash = bootstrapDatabaseIdentityHash(identity, contract, expectedOrigin);
console.log("[production-identity-bootstrap][PASS] exact_identity_group");
if (release) requireReleaseReceipt(release.directory, release.commit);
const result = spawnSync("bash", [DEPLOY_EXISTING_SCRIPT], {
cwd: ROOT_DIR,
stdio: "inherit",
env: {
...process.env,
...(release ? { RELEASE_GATE_RECEIPT_DIR: release.directory, RELEASE_GATE_EXPECTED_COMMIT: release.commit } : {}),
DEPLOY_SERVER: identity.deployServer,
DEPLOY_REFRESH_SERVER: refreshTarget,
EXPECTED_DEPLOY_SERVER: identity.deployServer,
EXPECTED_REMOTE_DIR: expectedRemoteDir,
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 instanceof ReleaseGateError
? 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();