文件
hyb-oa/scripts/qa/full-local-environment.mjs
T

259 行
19 KiB
JavaScript

#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { existsSync, readFileSync, readlinkSync, readdirSync, mkdirSync, mkdtempSync, chmodSync, writeFileSync, renameSync, rmSync } from "node:fs";
import os from "node:os";
import { createServer, createConnection } from "node:net";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { sourceSnapshot, compareSnapshots } from "./full-local-diagnostic.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const policyFiles = ["scripts/qa/full-local-environment.mjs", "scripts/qa/full-local-environment-inner.sh", "scripts/qa/local-postgres-proxy.py", "scripts/qa/browser-environment-probe.mjs", "scripts/qa/run-web-gates.sh", "scripts/qa/full-local-diagnostic.mjs", "client/playwright.config.ts"];
const sha = value => createHash("sha256").update(value).digest("hex");
const readJson = file => JSON.parse(readFileSync(file, "utf8"));
export function atomicEnvironmentJson(file, value) {
writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
renameSync(`${file}.tmp`, file);
}
function command(program, args) {
const result = spawnSync(program, args, { encoding: "utf8", timeout: 30000 });
if (result.status !== 0) throw new Error(`environment_command_failed:${path.basename(program)}`);
return result.stdout.trim();
}
export function environmentPolicyDigest() {
return sha(JSON.stringify(policyFiles.map(file => [file, sha(readFileSync(path.join(root, file)))])));
}
export function namespaceFingerprint(pid = "self") {
try { return sha(readlinkSync(`/proc/${pid}/ns/net`)); }
catch (error) { throw new Error(`environment_namespace_unreadable:${pid === "self" ? "self" : "other"}:${error.code ?? error.name}`); }
}
function parentPid(pid) {
const value = readFileSync(`/proc/${pid}/stat`, "utf8");
return Number(value.slice(value.lastIndexOf(")") + 2).split(" ")[1]);
}
function descendsFrom(pid, ancestor) {
const visited = new Set();
while (pid > 1 && !visited.has(pid)) {
if (pid === ancestor) return true;
visited.add(pid);
pid = parentPid(pid);
}
return false;
}
export async function requestSupervisorObservation(context) {
return await new Promise((resolve, reject) => {
const socket = createConnection(path.join(context.scratch, "supervisor.sock"));
let response = "";
socket.setTimeout(5000, () => socket.destroy(new Error("environment_supervisor_timeout")));
socket.on("error", () => reject(new Error("environment_supervisor_unavailable")));
socket.on("data", data => {
response += data;
if (response.length > 4096) socket.destroy(new Error("environment_supervisor_response_invalid"));
});
socket.on("end", () => {
try {
const observation = JSON.parse(response);
if (observation.runId !== context.runId || observation.pid !== context.supervisorPid || typeof observation.namespace !== "string" || observation.policyDigest !== context.policyDigest) throw new Error();
resolve(observation);
} catch { reject(new Error("environment_supervisor_response_invalid")); }
});
});
}
export function topologyMatches(addresses, routes, ipv6Routes) {
const names = addresses.map(entry => entry.ifname).sort();
if (JSON.stringify(names) !== JSON.stringify(["lo", "qaonline", "qapeer"])) return false;
for (const [name, address] of [["qaonline", "192.0.2.1"], ["qapeer", "192.0.2.2"]]) {
const entry = addresses.find(item => item.ifname === name);
if (!entry.flags?.includes("UP") || entry.operstate !== "UP" || !entry.addr_info?.some(item => item.local === address && item.prefixlen === 30)) return false;
}
return ![...routes, ...ipv6Routes].some(entry => entry.dst === "default" || entry.gateway);
}
function topology() {
return topologyMatches(JSON.parse(command("ip", ["-j", "address", "show"])), JSON.parse(command("ip", ["-j", "route", "show", "table", "all"])), JSON.parse(command("ip", ["-j", "-6", "route", "show", "table", "all"])));
}
export function validateEnvironmentFacts(context, facts) {
const failures = [];
if (context.schemaVersion !== 1 || context.profile !== "isolated-online-v1") failures.push("profile_invalid");
if (context.policyDigest !== facts.policyDigest) failures.push("policy_changed");
if (!facts.supervisorRecognized || !facts.descendantOfSupervisor) failures.push("supervisor_unverified");
if (context.inheritedNamespace !== facts.supervisorNamespace) failures.push("supervisor_namespace_changed");
if (!context.ownedNamespace || context.ownedNamespace === context.inheritedNamespace || context.ownedNamespace !== facts.currentNamespace) failures.push("namespace_unowned");
if (!facts.topologyMatched) failures.push("topology_invalid");
return failures;
}
export function verifyManagedEnvironment(contextFile = process.env.QA_ENVIRONMENT_CONTEXT, initialize = false) {
if (!contextFile || !existsSync(contextFile)) throw new Error("environment_context_required");
const context = readJson(contextFile);
const actualFile = path.resolve(contextFile);
if (actualFile !== path.join(context.environmentDir, "context.private.json")) throw new Error("environment_context_path_invalid");
const supervisorArgs = readFileSync(`/proc/${context.supervisorPid}/cmdline`, "utf8").split("\0");
const supervisor = JSON.parse(command(process.execPath, [path.join(root, "scripts/qa/full-local-environment.mjs"), "observe-supervisor", actualFile]));
const facts = {
policyDigest: environmentPolicyDigest(),
supervisorRecognized: supervisorArgs.includes(path.join(root, "scripts/qa/full-local-environment.mjs")),
descendantOfSupervisor: descendsFrom(process.pid, context.supervisorPid),
supervisorNamespace: supervisor.namespace,
supervisorObservation: supervisor,
currentNamespace: namespaceFingerprint(),
topologyMatched: topology(),
};
if (initialize && !context.ownedNamespace) context.ownedNamespace = facts.currentNamespace;
const failures = validateEnvironmentFacts(context, facts);
if (failures.length) throw new Error(`environment_invalid:${failures.join(",")}`);
if (initialize) atomicEnvironmentJson(actualFile, context);
return { context, facts };
}
export function validateEnvironmentReceipt(receipt, digest, requireGuest = true) {
const failures = [];
if (!receipt || receipt.schemaVersion !== 1 || receipt.profile !== "isolated-online-v1" || receipt.policyDigest !== digest) failures.push("environment_receipt_policy_invalid");
const namespace = receipt?.namespace;
if (receipt?.supervisorObservation?.source !== "live-supervisor-unix-ipc" || !Number.isFinite(Date.parse(receipt?.supervisorObservation?.sampledAt)) || receipt?.supervisorObservation?.namespace !== namespace?.inheritedRunner) failures.push("supervisor_observation_invalid");
if (!namespace?.inheritedRunner || !namespace.ownedChild || namespace.inheritedRunner === namespace.ownedChild || namespace.runnerCreated !== true || namespace.currentMatchesOwnedChild !== true || namespace.topologyMatched !== true) failures.push("environment_receipt_namespace_invalid");
if (receipt?.nativeOnlineProbe?.browserNamespace !== namespace?.ownedChild || receipt?.nativeOnlineProbe?.noNavigatorOverride !== true) failures.push("native_probe_namespace_invalid");
if (!receipt?.nativeOnlineProbe?.valid || receipt.nativeOnlineProbe.online !== true || !receipt.nativeOnlineProbe.browserMatchesOwnedChild) failures.push("native_probe_invalid");
if (requireGuest) {
const observed = receipt?.bootstrapObservation;
if (observed?.browserNamespace !== namespace?.ownedChild || observed?.noNavigatorOverride !== true || observed?.loginRequestCount !== 0 || observed?.documentStatus !== 200 || observed?.stayedOnLogin !== true) failures.push("guest_probe_evidence_invalid");
if (!observed || observed.observationPoint !== "guest-entry-black-box" || !observed.freshContext || observed.initialSessionHint !== false || !observed.listenerBeforeNavigation || !observed.noRouteMocks || !observed.loginFormVisible || observed.refreshRequestCount !== 0 || observed.nativeOnlineSamples?.beforeNavigation !== true || observed.nativeOnlineSamples?.documentStart !== true || observed.nativeOnlineSamples?.afterForm !== true || !observed.browserMatchesOwnedChild || !observed.valid) failures.push("guest_probe_invalid");
}
return failures;
}
export function environmentReceipt(contextFile = process.env.QA_ENVIRONMENT_CONTEXT, requireGuest = false) {
const { context, facts } = verifyManagedEnvironment(contextFile);
const nativeFile = path.join(context.environmentDir, "native-online.json");
const guestFile = path.join(context.environmentDir, "guest-entry.json");
const receipt = {
schemaVersion: 1, profile: context.profile, policyDigest: context.policyDigest,
platform: process.platform, runnerDigest: sha(readFileSync(path.join(root, "scripts/qa/full-local-environment.mjs"))), probeDigest: sha(readFileSync(path.join(root, "scripts/qa/browser-environment-probe.mjs"))),
supervisorObservation: { source: "live-supervisor-unix-ipc", sampledAt: facts.supervisorObservation.sampledAt, namespace: facts.supervisorNamespace },
namespace: { inheritedRunner: context.inheritedNamespace, ownedChild: context.ownedNamespace, currentMatchesOwnedChild: facts.currentNamespace === context.ownedNamespace, topologyMatched: facts.topologyMatched, runnerCreated: true },
nativeOnlineProbe: existsSync(nativeFile) ? readJson(nativeFile) : null,
bootstrapObservation: existsSync(guestFile) ? readJson(guestFile) : null,
};
const failures = validateEnvironmentReceipt(receipt, context.policyDigest, requireGuest);
if (failures.length) throw new Error(`environment_invalid:${failures.join(",")}`);
return receipt;
}
async function launch(mode) {
if (!["full-local", "full-local-diagnostic"].includes(mode)) throw new Error("environment_mode_invalid");
if (process.platform !== "linux") throw new Error("environment_platform_unsupported");
if (mode === "full-local-diagnostic") for (const name of ["REASON", "OPERATOR", "APPROVAL_REF"]) {
const value = process.env[`QA_DIAGNOSTIC_${name}`]?.trim();
if (!value || value.length > 500) throw new Error(`diagnostic_${name.toLowerCase()}_required`);
}
const runId = `${Date.now()}-${randomBytes(5).toString("hex")}`;
const base = path.resolve(process.env.QA_DIAGNOSTIC_REPORT_DIR ?? process.env.QA_FULL_REPORT_DIR ?? path.join(root, "artifacts/qa-flow", mode, runId));
if (!base.startsWith(`${path.join(root, "artifacts/qa-flow")}${path.sep}`)) throw new Error("environment_report_outside_artifacts");
if (existsSync(base) && readdirSync(base).length) throw new Error("environment_report_not_empty");
mkdirSync(base, { recursive: true, mode: 0o700 });
const environmentDir = path.join(base, "environment");
mkdirSync(environmentDir);
const scratch = mkdtempSync(path.join(os.tmpdir(), "hyb-qa-managed-"));
const socket = path.join(scratch, "socket");
mkdirSync(socket); chmodSync(scratch, 0o755); chmodSync(socket, 0o777);
const owner = `hyb-qa-${runId}`;
const record = { schemaVersion: 1, profile: "isolated-online-v1", policyDigest: environmentPolicyDigest(), platform: process.platform, mode, startedAt: new Date().toISOString(), status: "RUNNING", normalReleaseEligible: false };
const context = { schemaVersion: 1, profile: record.profile, policyDigest: record.policyDigest, runId, supervisorPid: process.pid, inheritedNamespace: namespaceFingerprint(), ownedNamespace: null, environmentDir, scratch, socket, mode };
const contextFile = path.join(environmentDir, "context.private.json");
atomicEnvironmentJson(contextFile, context);
let containerId = null;
let child = null;
let exitCode = 1;
let interrupted = null;
let supervisorServer = null;
const forward = signal => { interrupted = signal; if (child?.pid) { try { process.kill(-child.pid, signal); } catch {} } };
const onInt = () => forward("SIGINT");
const onTerm = () => forward("SIGTERM");
process.on("SIGINT", onInt); process.on("SIGTERM", onTerm);
try {
record.sourceBefore = sourceSnapshot();
const image = command("docker", ["image", "inspect", "--format", "{{.Id}}", "postgres:15-alpine"]);
containerId = command("docker", ["run", "--detach", "--pull=never", "--name", owner, "--label", `hyb.qa.owner=${owner}`, "--network", "none", "--tmpfs", "/var/lib/postgresql/data:rw,size=512m", "--mount", `type=bind,src=${socket},dst=/var/run/postgresql`, "-e", "POSTGRES_HOST_AUTH_METHOD=trust", image]);
if (!/^[a-f0-9]{64}$/.test(containerId)) throw new Error("environment_container_id_invalid");
record.databaseImageId = image;
writeFileSync(path.join(environmentDir, "container.private"), containerId, { mode: 0o600 });
let ready = false;
for (let attempt = 0; attempt < 60 && !interrupted; attempt += 1) {
if (spawnSync("docker", ["exec", containerId, "pg_isready", "-U", "postgres"], { stdio: "ignore", timeout: 10000 }).status === 0) { ready = true; break; }
await new Promise(resolve => setTimeout(resolve, 500));
}
if (!ready) throw new Error("environment_database_not_ready");
supervisorServer = createServer(connection => {
connection.on("error", () => connection.destroy());
try {
connection.end(JSON.stringify({ runId, pid: process.pid, namespace: namespaceFingerprint(), policyDigest: environmentPolicyDigest(), sampledAt: new Date().toISOString() }));
} catch { connection.destroy(); }
});
await new Promise((resolve, reject) => {
supervisorServer.once("error", reject);
supervisorServer.listen(path.join(scratch, "supervisor.sock"), resolve);
});
chmodSync(path.join(scratch, "supervisor.sock"), 0o600);
const env = {};
for (const name of ["PATH", "HOME", "LANG", "TERM", "CI", "NODE_OPTIONS", "PAYMENT_ORDER_PYTHON_BIN", "QA_DIAGNOSTIC_REASON", "QA_DIAGNOSTIC_OPERATOR", "QA_DIAGNOSTIC_APPROVAL_REF"]) if (process.env[name] !== undefined) env[name] = process.env[name];
Object.assign(env, { QA_ENVIRONMENT_CONTEXT: contextFile, QA_FULL_REPORT_DIR: path.join(base, "results"), QA_DIAGNOSTIC_REPORT_DIR: path.join(base, "results"), QA_MANAGED_SCRATCH: scratch, QA_MANAGED_SOCKET: socket, QA_HOST: "127.0.0.1", QA_PORT: "23000", QA_DB_HOST: "127.0.0.1", QA_DB_PORT: "25432", QA_DB_CLIENT: "host", QA_DB_USER: "postgres", QA_DB_PASSWORD: "local_qa_only", QA_KEEP_DB: "0", QA_SKIP_CLIENT_BUILD: "0", E2E_MODE: "local", WECOM_ENABLED: "false", EMAIL_ENABLED: "false", OIDC_ENABLED: "false", STORAGE_DRIVER: "local", NODE_ENV: "test", npm_config_offline: "true", NO_COLOR: "1" });
const outcome = await new Promise(resolve => {
child = spawn("unshare", ["-Urn", "bash", path.join(root, "scripts/qa/full-local-environment-inner.sh"), mode], { cwd: root, env, stdio: "inherit", detached: true });
child.once("error", () => resolve({ code: 1, signal: null }));
child.once("close", (code, signal) => resolve({ code, signal }));
});
child = null;
exitCode = outcome.code ?? 1;
record.childExit = outcome.code; record.childSignal = outcome.signal;
const completionFile = path.join(environmentDir, "completed.json");
if (existsSync(completionFile)) {
const completion = readJson(completionFile);
record.environment = completion;
record.status = validateEnvironmentReceipt(completion, environmentPolicyDigest(), true).length ? "ENVIRONMENT_INVALID" : "PASS";
} else record.status = "ENVIRONMENT_INVALID";
if (record.status !== "PASS") exitCode = mode === "full-local-diagnostic" ? 2 : 1;
} catch (error) {
record.status = "ENVIRONMENT_INVALID";
record.error = error.message.startsWith("environment_") ? error.message : "environment_internal_error";
exitCode = mode === "full-local-diagnostic" ? 2 : 1;
} finally {
if (supervisorServer) await new Promise(resolve => supervisorServer.close(resolve));
record.cleanup = { containerOwnerVerified: false, containerRemoved: containerId === null, scratchRemoved: false };
if (containerId) {
try {
const identity = command("docker", ["inspect", "--format", '{{.Id}}|{{index .Config.Labels "hyb.qa.owner"}}|{{.HostConfig.NetworkMode}}', containerId]);
if (identity !== `${containerId}|${owner}|none`) throw new Error("environment_cleanup_owner_mismatch");
record.cleanup.containerOwnerVerified = true;
spawnSync("docker", ["exec", containerId, "chmod", "0777", "/var/run/postgresql"], { stdio: "ignore", timeout: 10000 });
command("docker", ["rm", "-f", containerId]);
record.cleanup.containerRemoved = true;
} catch { record.cleanup.error = "owned_container_cleanup_failed"; }
}
if (record.cleanup.containerRemoved) {
try { rmSync(scratch, { recursive: true }); record.cleanup.scratchRemoved = true; } catch { record.cleanup.error = "owned_scratch_cleanup_failed"; }
}
if (!record.cleanup.containerRemoved || !record.cleanup.scratchRemoved) { record.status = "ENVIRONMENT_INVALID"; exitCode = mode === "full-local-diagnostic" ? 2 : 1; }
try {
record.sourceAfter = sourceSnapshot();
Object.assign(record, compareSnapshots(record.sourceBefore, record.sourceAfter));
if (record.candidateIntegrityStatus !== "UNCHANGED") exitCode = mode === "full-local-diagnostic" ? 2 : 1;
} catch { record.candidateIntegrityStatus = "UNKNOWN"; record.repositorySnapshotError = "post_run_snapshot_unavailable"; exitCode = mode === "full-local-diagnostic" ? 2 : 1; }
record.interrupted = interrupted; record.endedAt = new Date().toISOString(); record.exitCode = exitCode;
atomicEnvironmentJson(path.join(environmentDir, "result.json"), record);
process.off("SIGINT", onInt); process.off("SIGTERM", onTerm);
}
console.log(`[environment] ${record.status}; ${mode === "full-local-diagnostic" ? "PARTIAL; " : ""}exit=${exitCode}; no release activation`);
process.exitCode = exitCode;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
const action = process.argv[2];
if (action === "observe-supervisor") console.log(JSON.stringify(await requestSupervisorObservation(readJson(process.argv[3]))));
else if (action === "initialize") verifyManagedEnvironment(undefined, true);
else if (action === "verify") environmentReceipt(undefined, false);
else if (action === "report" || action === "report-guest") console.log(JSON.stringify(environmentReceipt(undefined, action === "report-guest")));
else if (action === "complete") {
const receipt = environmentReceipt(undefined, false);
const { context } = verifyManagedEnvironment();
atomicEnvironmentJson(path.join(context.environmentDir, "completed.json"), receipt);
} else await launch(action);
} catch (error) { console.error(`[environment] ${error.message.startsWith("environment_") ? error.message : `environment_verification_failed:${error.code ?? error.name}`}`); process.exitCode = 1; }
}