实现(08-08-production-deployment-execution): 校准生产身份只读门禁
这个提交包含在:
@@ -42,15 +42,28 @@ deployment_contract_load "$DEPLOY_SERVER" "$DEPLOY_CONTRACT_UNIT" contract \
|
||||
|
||||
echo "[deploy-existing] 已读取受控 unit 合同;路径与标识不回显"
|
||||
|
||||
if [[ -z "${EXPECTED_PRODUCTION_ORIGIN+x}" ]]; then
|
||||
if [[ -z "${EXPECTED_PRODUCTION_ORIGIN:-}" ]]; then
|
||||
fail "生产部署必须显式提供 EXPECTED_PRODUCTION_ORIGIN(仅进程环境)"
|
||||
fi
|
||||
if [[ -z "${EXPECTED_PRODUCTION_DB_NAME+x}" ]]; then
|
||||
fail "生产部署必须显式提供 EXPECTED_PRODUCTION_DB_NAME(仅进程环境)"
|
||||
if [[ -z "${EXPECTED_PRODUCTION_DB_NAME_SHA256:-}" ]]; then
|
||||
fail "生产部署必须由身份引导器提供 EXPECTED_PRODUCTION_DB_NAME_SHA256"
|
||||
fi
|
||||
if [[ -z "${EXPECTED_PENDING_MIGRATIONS+x}" ]]; then
|
||||
fail "生产部署必须显式提供 EXPECTED_PENDING_MIGRATIONS(可为空)"
|
||||
fi
|
||||
if [[ -z "${EXPECTED_DEPLOY_SERVER:-}" ]]; then
|
||||
fail "生产部署必须由身份引导器提供 EXPECTED_DEPLOY_SERVER"
|
||||
fi
|
||||
if [[ -z "${EXPECTED_REMOTE_DIR:-}" ]]; then
|
||||
fail "生产部署必须由身份引导器提供 EXPECTED_REMOTE_DIR"
|
||||
fi
|
||||
if [[ -z "${EXPECTED_COMPOSE_PROJECT_NAME:-}" ]]; then
|
||||
fail "生产部署必须由身份引导器提供 EXPECTED_COMPOSE_PROJECT_NAME"
|
||||
fi
|
||||
case "${PRODUCTION_READ_ONLY_PREFLIGHT:-0}" in
|
||||
0|1) ;;
|
||||
*) fail "PRODUCTION_READ_ONLY_PREFLIGHT 只能为 0 或 1" ;;
|
||||
esac
|
||||
[[ -f "$ROOT_DIR/$PROVENANCE_SCRIPT" ]] || fail "缺少发布来源校验脚本"
|
||||
[[ -f "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT" ]] || fail "缺少生产迁移 preflight 脚本"
|
||||
|
||||
@@ -64,8 +77,11 @@ source_commit="$(node -e 'const value = JSON.parse(process.argv[1]); process.std
|
||||
echo ">>> 0.2 生产目标与迁移 preflight(只读,先于 deploy.sh)"
|
||||
PRODUCTION_PREFLIGHT_REPORT_ROOT="${PRODUCTION_PREFLIGHT_REPORT_ROOT:-artifacts/production-preflight-$(date +%Y%m%d-%H%M%S)}"
|
||||
EXPECTED_PRODUCTION_ORIGIN="$EXPECTED_PRODUCTION_ORIGIN" \
|
||||
EXPECTED_PRODUCTION_DB_NAME="$EXPECTED_PRODUCTION_DB_NAME" \
|
||||
EXPECTED_PRODUCTION_DB_NAME_SHA256="$EXPECTED_PRODUCTION_DB_NAME_SHA256" \
|
||||
EXPECTED_PENDING_MIGRATIONS="$EXPECTED_PENDING_MIGRATIONS" \
|
||||
EXPECTED_DEPLOY_SERVER="$EXPECTED_DEPLOY_SERVER" \
|
||||
EXPECTED_REMOTE_DIR="$EXPECTED_REMOTE_DIR" \
|
||||
EXPECTED_COMPOSE_PROJECT_NAME="$EXPECTED_COMPOSE_PROJECT_NAME" \
|
||||
DEPLOY_SERVER="$DEPLOY_SERVER" \
|
||||
REMOTE_DIR="${contract[REMOTE_DIR]}" \
|
||||
REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \
|
||||
@@ -78,6 +94,11 @@ PRODUCTION_PREFLIGHT_REPORT_DIR="$PRODUCTION_PREFLIGHT_REPORT_ROOT/pre" \
|
||||
node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT" \
|
||||
|| fail "生产目标或迁移 preflight 未通过;未进入 deploy.sh"
|
||||
|
||||
if [[ "${PRODUCTION_READ_ONLY_PREFLIGHT:-0}" == "1" ]]; then
|
||||
echo "[deploy-existing][PASS] 只读身份、迁移与回滚窗口门禁通过;未进入 deploy.sh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DEPLOY_SERVER="$DEPLOY_SERVER" \
|
||||
REMOTE_DIR="${contract[REMOTE_DIR]}" \
|
||||
REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \
|
||||
@@ -93,8 +114,11 @@ bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
|
||||
|
||||
echo ">>> 6.1 部署后 migration status 与目标只读复核"
|
||||
EXPECTED_PRODUCTION_ORIGIN="$EXPECTED_PRODUCTION_ORIGIN" \
|
||||
EXPECTED_PRODUCTION_DB_NAME="$EXPECTED_PRODUCTION_DB_NAME" \
|
||||
EXPECTED_PRODUCTION_DB_NAME_SHA256="$EXPECTED_PRODUCTION_DB_NAME_SHA256" \
|
||||
EXPECTED_PENDING_MIGRATIONS="" \
|
||||
EXPECTED_DEPLOY_SERVER="$EXPECTED_DEPLOY_SERVER" \
|
||||
EXPECTED_REMOTE_DIR="$EXPECTED_REMOTE_DIR" \
|
||||
EXPECTED_COMPOSE_PROJECT_NAME="$EXPECTED_COMPOSE_PROJECT_NAME" \
|
||||
DEPLOY_SERVER="$DEPLOY_SERVER" \
|
||||
REMOTE_DIR="${contract[REMOTE_DIR]}" \
|
||||
REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Read-only identity bootstrap. The caller captures the database identity in
|
||||
# memory and must never forward this script's stdout to an ordinary log.
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
printf '[production-identity-bootstrap][FAIL] code=%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE COMPOSE_PROJECT_NAME \
|
||||
EXPECTED_PRODUCTION_ORIGIN_SHA256; do
|
||||
[[ -n "${!key:-}" ]] || fail "${key}_MISSING"
|
||||
done
|
||||
|
||||
[[ "$EXPECTED_PRODUCTION_ORIGIN_SHA256" =~ ^[0-9a-f]{64}$ ]] \
|
||||
|| fail "PROD_TARGET_ORIGIN_HASH_INVALID"
|
||||
cd "$REMOTE_DIR"
|
||||
[[ -f "$REMOTE_ENV_FILE" && ! -L "$REMOTE_ENV_FILE" && -r "$REMOTE_ENV_FILE" ]] \
|
||||
|| fail "PROD_TARGET_ENV_UNREADABLE"
|
||||
|
||||
dotenv_value() {
|
||||
local key="$1"
|
||||
local value
|
||||
value="$(awk -v wanted="$key" '
|
||||
$0 !~ /^[[:space:]]*#/ && $0 ~ "^[[:space:]]*" wanted "=" {
|
||||
sub("^[[:space:]]*" wanted "=", "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$REMOTE_ENV_FILE")" || return 1
|
||||
value="${value%$'\r'}"
|
||||
case "$value" in
|
||||
\"*\") value="${value:1:${#value}-2}" ;;
|
||||
\'*\') value="${value:1:${#value}-2}" ;;
|
||||
esac
|
||||
[[ -n "$value" ]] || return 1
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
hash_text() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s' "$1" | sha256sum | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
printf '%s' "$1" | shasum -a 256 | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
reject_non_production_identity() {
|
||||
local value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
|
||||
[[ "$value" != *"<"* && "$value" != *">"* ]] || return 1
|
||||
[[ "$value" != *"localhost"* && "$value" != *"127.0.0.1"* \
|
||||
&& "$value" != *"::1"* && "$value" != *"hyb_oa_regression"* \
|
||||
&& "$value" != *"staging"* && "$value" != *"stage"* \
|
||||
&& "$value" != *"regression"* && "$value" != *"shadow"* \
|
||||
&& "$value" != *"/test"* ]]
|
||||
}
|
||||
|
||||
actual_origin="$(dotenv_value APP_ORIGIN)" || fail "PROD_TARGET_ORIGIN_MISSING"
|
||||
actual_db_name="$(dotenv_value POSTGRES_DB)" || fail "PROD_TARGET_DB_NAME_MISSING"
|
||||
[[ "$(hash_text "$actual_origin")" == "$EXPECTED_PRODUCTION_ORIGIN_SHA256" ]] \
|
||||
|| fail "PROD_TARGET_ORIGIN_MISMATCH"
|
||||
reject_non_production_identity "$actual_db_name" \
|
||||
|| fail "PROD_TARGET_DB_NON_PRODUCTION_IDENTITY"
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
compose_cmd=(docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
compose_cmd=(docker-compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
|
||||
else
|
||||
fail "PROD_TARGET_COMPOSE_UNAVAILABLE"
|
||||
fi
|
||||
|
||||
mapfile -t server_ids < <(
|
||||
docker ps \
|
||||
--filter "label=com.docker.compose.project=$COMPOSE_PROJECT_NAME" \
|
||||
--filter "label=com.docker.compose.service=server" \
|
||||
--format '{{.ID}}' 2>/dev/null || true
|
||||
)
|
||||
[[ "${#server_ids[@]}" -eq 1 ]] || fail "PROD_TARGET_RUNTIME_NOT_UNIQUE"
|
||||
runtime_origin="$(
|
||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "${server_ids[0]}" \
|
||||
| sed -n 's/^APP_ORIGIN=//p' | tail -n 1
|
||||
)"
|
||||
[[ "$(hash_text "$runtime_origin")" == "$EXPECTED_PRODUCTION_ORIGIN_SHA256" ]] \
|
||||
|| fail "PROD_TARGET_RUNTIME_ORIGIN_MISMATCH"
|
||||
|
||||
# Only the comparison baseline crosses the SSH channel. The database name
|
||||
# itself remains remote and cannot be reconstructed from task evidence.
|
||||
printf '[production-identity-bootstrap][DB_SHA256] %s\n' "$(hash_text "$actual_db_name")"
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/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();
|
||||
@@ -65,8 +65,8 @@ reject_non_production_identity() {
|
||||
[[ "$value" != *"<"* && "$value" != *">"* ]] || return 1
|
||||
[[ "$value" != *"localhost"* && "$value" != *"127.0.0.1"* \
|
||||
&& "$value" != *"::1"* && "$value" != *"hyb_oa_regression"* \
|
||||
&& "$value" != *"trialrun"* && "$value" != *"staging"* \
|
||||
&& "$value" != *"stage"* && "$value" != *"regression"* \
|
||||
&& "$value" != *"staging"* && "$value" != *"stage"* \
|
||||
&& "$value" != *"regression"* \
|
||||
&& "$value" != *"shadow"* && "$value" != *"/test"* ]]
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const 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;
|
||||
const FORBIDDEN_IDENTITY_TOKEN_PATTERN = /(^|[._-])(local|localhost|dev|development|test|testing|stage|staging|trialrun|regression|shadow|example)([._-]|$)/u;
|
||||
const FORBIDDEN_IDENTITY_TOKEN_PATTERN = /(^|[._/-])(local|localhost|dev|development|test|testing|stage|staging|regression|shadow|example)([._/-]|$)/u;
|
||||
const PRISMA_STATUS_KNOWN_PATTERN = /(?:database schema is up to date|have not yet been applied|no pending migrations)/iu;
|
||||
|
||||
export class ProductionPreflightError extends Error {
|
||||
@@ -135,6 +135,34 @@ function assertSafeContractValue(name, value, pattern) {
|
||||
if (!pattern.test(String(value ?? ""))) fail(`${name}_INVALID`);
|
||||
}
|
||||
|
||||
function assertProductionIdentityValue(name, value) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (!normalized || normalized.includes("<") || normalized.includes(">") || FORBIDDEN_IDENTITY_TOKEN_PATTERN.test(normalized)) {
|
||||
fail(`${name}_NON_PRODUCTION_IDENTITY`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactIdentity(name, actual, expected) {
|
||||
if (expected == null || !String(expected).trim()) fail(`EXPECTED_${name}_MISSING`);
|
||||
if (actual !== String(expected).trim()) fail(`${name}_MISMATCH`);
|
||||
}
|
||||
|
||||
export function validateExactProductionIdentity({
|
||||
deployServer,
|
||||
remoteDir,
|
||||
composeProjectName,
|
||||
expectedDeployServer,
|
||||
expectedRemoteDir,
|
||||
expectedComposeProjectName,
|
||||
}) {
|
||||
assertProductionIdentityValue("DEPLOY_SERVER", deployServer);
|
||||
assertProductionIdentityValue("REMOTE_DIR", remoteDir);
|
||||
assertProductionIdentityValue("COMPOSE_PROJECT_NAME", composeProjectName);
|
||||
assertExactIdentity("DEPLOY_SERVER", deployServer, expectedDeployServer);
|
||||
assertExactIdentity("REMOTE_DIR", remoteDir, expectedRemoteDir);
|
||||
assertExactIdentity("COMPOSE_PROJECT_NAME", composeProjectName, expectedComposeProjectName);
|
||||
}
|
||||
|
||||
function gitText(args) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: ROOT_DIR,
|
||||
@@ -216,14 +244,18 @@ function main() {
|
||||
const postgresDataDir = String(process.env.POSTGRES_DATA_DIR ?? "").trim();
|
||||
const storageHostDir = String(process.env.STORAGE_HOST_DIR ?? "").trim();
|
||||
const expectedOriginRaw = process.env.EXPECTED_PRODUCTION_ORIGIN;
|
||||
const expectedDbNameRaw = process.env.EXPECTED_PRODUCTION_DB_NAME;
|
||||
const expectedDbNameHashRaw = process.env.EXPECTED_PRODUCTION_DB_NAME_SHA256;
|
||||
const expectedPendingRaw = process.env.EXPECTED_PENDING_MIGRATIONS;
|
||||
const expectedDeployServer = process.env.EXPECTED_DEPLOY_SERVER;
|
||||
const expectedRemoteDir = process.env.EXPECTED_REMOTE_DIR;
|
||||
const expectedComposeProjectName = process.env.EXPECTED_COMPOSE_PROJECT_NAME;
|
||||
if (expectedOriginRaw == null) fail("EXPECTED_PRODUCTION_ORIGIN_MISSING");
|
||||
if (expectedDbNameRaw == null) fail("EXPECTED_PRODUCTION_DB_NAME_MISSING");
|
||||
if (expectedDbNameHashRaw == null) fail("EXPECTED_PRODUCTION_DB_NAME_SHA256_MISSING");
|
||||
if (expectedPendingRaw == null) fail("EXPECTED_PENDING_MIGRATIONS_MISSING");
|
||||
|
||||
const expectedOrigin = validateProductionOrigin(expectedOriginRaw);
|
||||
const expectedDbName = validateProductionDbName(expectedDbNameRaw);
|
||||
const expectedDbNameHash = String(expectedDbNameHashRaw).trim();
|
||||
if (!/^[0-9a-f]{64}$/u.test(expectedDbNameHash)) fail("EXPECTED_PRODUCTION_DB_NAME_SHA256_INVALID");
|
||||
const expectedPending = parseMigrationList(expectedPendingRaw, {
|
||||
field: "EXPECTED_PENDING_MIGRATIONS",
|
||||
});
|
||||
@@ -234,6 +266,14 @@ function main() {
|
||||
assertSafeContractValue("STORAGE_HOST_DIR", storageHostDir, SAFE_ABSOLUTE_PATH_PATTERN);
|
||||
assertSafeContractValue("COMPOSE_FILE", composeFile, SAFE_COMPOSE_FILE_PATTERN);
|
||||
assertSafeContractValue("COMPOSE_PROJECT_NAME", composeProjectName, SAFE_PROJECT_PATTERN);
|
||||
validateExactProductionIdentity({
|
||||
deployServer,
|
||||
remoteDir,
|
||||
composeProjectName,
|
||||
expectedDeployServer,
|
||||
expectedRemoteDir,
|
||||
expectedComposeProjectName,
|
||||
});
|
||||
|
||||
const candidateCommit = String(
|
||||
process.env.CANDIDATE_COMMIT ?? gitText(["rev-parse", "--verify", "HEAD"]),
|
||||
@@ -266,7 +306,7 @@ function main() {
|
||||
`POSTGRES_DATA_DIR=${shellQuote(postgresDataDir)}`,
|
||||
`STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`,
|
||||
`EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`,
|
||||
`EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(sha256Text(expectedDbName))}`,
|
||||
`EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(expectedDbNameHash)}`,
|
||||
`CANDIDATE_MIGRATIONS_CSV=${shellQuote(candidateManifest.join(","))}`,
|
||||
`EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`,
|
||||
"bash -s",
|
||||
@@ -283,7 +323,7 @@ function main() {
|
||||
const rawStatus = extractPrismaStatus(stdout);
|
||||
if (rawStatus != null) {
|
||||
validatePrismaStatusText(rawStatus);
|
||||
const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin, expectedDbName]);
|
||||
const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin]);
|
||||
writeFileSync(path.join(reportDir, "prisma-migrate-status.txt"), `${sanitizedStatus}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
|
||||
@@ -43,6 +43,14 @@ const productionPreflightRemoteScriptPath = path.join(
|
||||
rootDir,
|
||||
'deploy/trialrun/production-preflight-remote.sh',
|
||||
);
|
||||
const productionIdentityBootstrapScriptPath = path.join(
|
||||
rootDir,
|
||||
'deploy/trialrun/production-identity-bootstrap.mjs',
|
||||
);
|
||||
const productionIdentityBootstrapRemoteScriptPath = path.join(
|
||||
rootDir,
|
||||
'deploy/trialrun/production-identity-bootstrap-remote.sh',
|
||||
);
|
||||
const dockerfilePath = path.join(rootDir, 'deploy/trialrun/Dockerfile');
|
||||
const timerScriptPath = path.resolve(
|
||||
process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH
|
||||
@@ -279,6 +287,8 @@ const deploymentRefreshHelperContent = readFile(deploymentRefreshHelperPath);
|
||||
const remotePreflightScriptContent = readFile(remotePreflightScriptPath);
|
||||
const productionPreflightScriptContent = readFile(productionPreflightScriptPath);
|
||||
const productionPreflightRemoteScriptContent = readFile(productionPreflightRemoteScriptPath);
|
||||
const productionIdentityBootstrapScriptContent = readFile(productionIdentityBootstrapScriptPath);
|
||||
const productionIdentityBootstrapRemoteScriptContent = readFile(productionIdentityBootstrapRemoteScriptPath);
|
||||
const dockerfileContent = readFile(dockerfilePath);
|
||||
const timerScriptContent = readFile(timerScriptPath);
|
||||
const automaticBackupTimerScriptContent = readFile(automaticBackupTimerScriptPath);
|
||||
@@ -604,8 +614,12 @@ for (const requiredFragment of [
|
||||
|
||||
for (const requiredFragment of [
|
||||
'EXPECTED_PRODUCTION_ORIGIN',
|
||||
'EXPECTED_PRODUCTION_DB_NAME',
|
||||
'EXPECTED_PRODUCTION_DB_NAME_SHA256',
|
||||
'EXPECTED_PENDING_MIGRATIONS',
|
||||
'EXPECTED_DEPLOY_SERVER',
|
||||
'EXPECTED_REMOTE_DIR',
|
||||
'EXPECTED_COMPOSE_PROJECT_NAME',
|
||||
'PRODUCTION_READ_ONLY_PREFLIGHT',
|
||||
'production-preflight.mjs',
|
||||
'CANDIDATE_COMMIT',
|
||||
'PRODUCTION_PREFLIGHT_REPORT_DIR',
|
||||
@@ -616,6 +630,34 @@ for (const requiredFragment of [
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'HISTORICAL_TARGET_COMMIT',
|
||||
'HISTORICAL_PROJECT_COMMIT',
|
||||
'discoverRefreshTarget',
|
||||
'assertExactHistoricalContract',
|
||||
'bootstrapDatabaseIdentityHash',
|
||||
'EXPECTED_PRODUCTION_DB_NAME_SHA256',
|
||||
'DEPLOY_EXISTING_SCRIPT',
|
||||
]) {
|
||||
if (!productionIdentityBootstrapScriptContent.includes(requiredFragment)) {
|
||||
errors.push(`production-identity-bootstrap.mjs 缺少生产身份引导合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
for (const requiredFragment of [
|
||||
'EXPECTED_PRODUCTION_ORIGIN_SHA256',
|
||||
'PROD_TARGET_RUNTIME_ORIGIN_MISMATCH',
|
||||
'PROD_TARGET_DB_NON_PRODUCTION_IDENTITY',
|
||||
'DB_SHA256',
|
||||
]) {
|
||||
if (!productionIdentityBootstrapRemoteScriptContent.includes(requiredFragment)) {
|
||||
errors.push(`production-identity-bootstrap-remote.sh 缺少只读身份合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
if (productionIdentityBootstrapRemoteScriptContent.includes('prisma migrate status')
|
||||
|| productionIdentityBootstrapRemoteScriptContent.includes('migrate deploy')) {
|
||||
errors.push('production-identity-bootstrap-remote.sh 不得进入 Prisma status 或迁移写入');
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'systemctl show',
|
||||
'DEPLOY_CONTRACT_KEYS',
|
||||
@@ -735,6 +777,10 @@ if (deployExistingScriptContent.indexOf('node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_S
|
||||
> deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) {
|
||||
errors.push('deploy-existing.sh 必须在 deploy.sh 前执行生产目标与迁移 preflight');
|
||||
}
|
||||
if (deployExistingScriptContent.indexOf('PRODUCTION_READ_ONLY_PREFLIGHT')
|
||||
> deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) {
|
||||
errors.push('deploy-existing.sh 的只读停止点必须位于 deploy.sh 前');
|
||||
}
|
||||
if (!deployScriptContent.includes('DEPLOY_STATUS_OUTPUT_MODE')) {
|
||||
errors.push('deploy.sh 必须支持受控发布 wrapper 的 quiet migration status 输出模式');
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户