150 lines
9.7 KiB
JavaScript
150 lines
9.7 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("node:fs/promises");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const crypto = require("node:crypto");
|
|
const { run } = require("./process-runner.cjs");
|
|
|
|
class ProductionAcceptanceHarness {
|
|
constructor(root) {
|
|
this.root = root;
|
|
this.paths = {
|
|
remote: path.join(root, "gitea", "owner", "app.git"),
|
|
source: path.join(root, "workspace", "app"),
|
|
server: path.join(root, "server", "appdata", "app"),
|
|
releases: path.join(root, "releases"),
|
|
config: path.join(root, "user-data", "forgeflow-config.json"),
|
|
keys: path.join(root, "keys"),
|
|
};
|
|
this.state = { installed: false, version: null, tokenVersion: 1, auth: null, liveSha: null, previousSha: null, healthy: false, deployment: null, recovery: null, hostFingerprint: "SHA256:fixture-host", keyReadOnly: true };
|
|
}
|
|
|
|
static async create() {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "forgeflow-production-acceptance-"));
|
|
const harness = new ProductionAcceptanceHarness(root);
|
|
await harness.provision();
|
|
return harness;
|
|
}
|
|
|
|
async provision() {
|
|
await Promise.all(Object.values(this.paths).filter((value) => !path.extname(value)).map((directory) => fs.mkdir(directory, { recursive: true })));
|
|
await fs.mkdir(path.dirname(this.paths.remote), { recursive: true });
|
|
await run("git", ["init", "--bare", this.paths.remote], { cwd: this.root, timeout: 30_000 });
|
|
await fs.mkdir(this.paths.source, { recursive: true });
|
|
await run("git", ["init", "-b", "main"], { cwd: this.paths.source, timeout: 30_000 });
|
|
await run("git", ["config", "user.name", "ForgeFlow Acceptance"], { cwd: this.paths.source });
|
|
await run("git", ["config", "user.email", "acceptance@example.invalid"], { cwd: this.paths.source });
|
|
await fs.writeFile(path.join(this.paths.source, "compose.yml"), "services:\n app:\n image: forgeflow-fixture:latest\n", "utf8");
|
|
await fs.writeFile(path.join(this.paths.source, "README.md"), "# Acceptance fixture\n", "utf8");
|
|
await run("git", ["add", "."], { cwd: this.paths.source });
|
|
await run("git", ["commit", "-m", "feat: initial fixture"], { cwd: this.paths.source });
|
|
await run("git", ["remote", "add", "origin", this.paths.remote], { cwd: this.paths.source });
|
|
await run("git", ["push", "-u", "origin", "main"], { cwd: this.paths.source, timeout: 30_000 });
|
|
this.initialSha = (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim();
|
|
await fs.mkdir(this.paths.releases, { recursive: true });
|
|
await fs.mkdir(path.dirname(this.paths.config), { recursive: true });
|
|
await fs.mkdir(this.paths.keys, { recursive: true });
|
|
await fs.writeFile(path.join(this.paths.keys, "deploy_key.pub"), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture forgeflow-acceptance\n", "utf8");
|
|
}
|
|
|
|
async cleanup() { await fs.rm(this.root, { recursive: true, force: true }); }
|
|
|
|
async install(version = "0.10.0", mode = "installed") {
|
|
this.state.installed = true; this.state.version = version; this.state.mode = mode;
|
|
await this.saveConfig({ schemaVersion: 12, version, mode });
|
|
return structuredClone(this.state);
|
|
}
|
|
|
|
async migrate(targetVersion = "1.0.0") {
|
|
if (!this.state.installed) throw new Error("Clean installation is required before migration.");
|
|
const previous = JSON.parse(await fs.readFile(this.paths.config, "utf8"));
|
|
await fs.writeFile(`${this.paths.config}.backup`, JSON.stringify(previous, null, 2), "utf8");
|
|
this.state.version = targetVersion;
|
|
await this.saveConfig({ ...previous, schemaVersion: 13, version: targetVersion, migratedAt: new Date().toISOString() });
|
|
return { previousVersion: previous.version, version: targetVersion, backup: `${this.paths.config}.backup` };
|
|
}
|
|
|
|
async saveConfig(data) { await fs.writeFile(this.paths.config, `${JSON.stringify(data, null, 2)}\n`, "utf8"); }
|
|
rotateToken() { this.state.tokenVersion += 1; return { tokenVersion: this.state.tokenVersion }; }
|
|
authenticate(type, options = {}) {
|
|
if (!['password', 'ssh-key'].includes(type)) throw new Error("Unsupported authentication fixture.");
|
|
if (type === 'ssh-key' && options.hostFingerprint !== this.state.hostFingerprint) throw new Error("SSH host fingerprint changed.");
|
|
this.state.auth = type; return { authenticated: true, type };
|
|
}
|
|
setKeyAccess(readOnly) { this.state.keyReadOnly = readOnly; }
|
|
|
|
async createCommit(message = "fix: acceptance change") {
|
|
const target = path.join(this.paths.source, "fixture.txt");
|
|
await fs.writeFile(target, `${crypto.randomUUID()}\n`, "utf8");
|
|
await run("git", ["add", "fixture.txt"], { cwd: this.paths.source });
|
|
await run("git", ["commit", "-m", message], { cwd: this.paths.source });
|
|
await run("git", ["push", "origin", "main"], { cwd: this.paths.source });
|
|
return (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim();
|
|
}
|
|
|
|
plan(sha, mode = "server-git") {
|
|
return { id: crypto.randomUUID(), evidenceHash: crypto.createHash("sha256").update(JSON.stringify({ sha, mode, liveSha: this.state.liveSha, keyReadOnly: this.state.keyReadOnly })).digest("hex"), sha, mode, previousSha: this.state.liveSha };
|
|
}
|
|
|
|
async deploy(plan, fault = null) {
|
|
if (!this.state.auth) throw new Error("Server authentication is required.");
|
|
if (plan.mode === "server-git" && !this.state.keyReadOnly) throw new Error("Writable deploy key rejected.");
|
|
if (this.plan(plan.sha, plan.mode).evidenceHash !== plan.evidenceHash) throw new Error("Stale reconciliation plan.");
|
|
this.state.recovery = structuredClone(this.state);
|
|
this.state.deployment = { id: crypto.randomUUID(), sha: plan.sha, mode: plan.mode, status: "running" };
|
|
if (fault === "fetch-network") return this.fail("Network interrupted during fetch", false);
|
|
await fs.mkdir(this.paths.server, { recursive: true });
|
|
await fs.writeFile(path.join(this.paths.server, "compose.yml"), await fs.readFile(path.join(this.paths.source, "compose.yml")));
|
|
if (fault === "activation-network") return this.fail("Network interrupted during activation", true);
|
|
if (fault === "shutdown") { this.state.deployment.status = "interrupted"; return structuredClone(this.state.deployment); }
|
|
this.state.previousSha = this.state.liveSha;
|
|
this.state.liveSha = plan.sha;
|
|
this.state.healthy = fault !== "unhealthy";
|
|
this.state.deployment.status = this.state.healthy ? "success" : "failed";
|
|
return structuredClone(this.state.deployment);
|
|
}
|
|
|
|
fail(message, partial) { this.state.deployment.status = "failed"; this.state.deployment.failure = { message, partial }; return structuredClone(this.state.deployment); }
|
|
recover() {
|
|
if (this.state.deployment?.status !== "interrupted") throw new Error("No interrupted deployment to recover.");
|
|
this.state.deployment.status = this.state.liveSha === this.state.deployment.sha && this.state.healthy ? "success" : "failed";
|
|
return structuredClone(this.state.deployment);
|
|
}
|
|
rollback(targetSha) {
|
|
if (!targetSha || targetSha !== this.state.previousSha) throw new Error("Rollback target is not the exact recorded previous SHA.");
|
|
[this.state.liveSha, this.state.previousSha] = [targetSha, this.state.liveSha]; this.state.healthy = true;
|
|
return { status: "rolled-back", liveSha: this.state.liveSha };
|
|
}
|
|
|
|
adoptExisting(sha = this.initialSha) { this.state.liveSha = sha; this.state.healthy = true; return { linked: true, liveSha: sha, preserved: true }; }
|
|
externalUpdate(sha) { this.state.liveSha = sha; this.state.healthy = true; return { reconciled: true, liveSha: sha }; }
|
|
rotateDeployKey() { if (!this.state.keyReadOnly) throw new Error("Candidate deploy key is writable."); this.state.keyVersion = (this.state.keyVersion || 1) + 1; return { rotated: true, keyVersion: this.state.keyVersion }; }
|
|
revokeDeployKey() { this.state.keyRevoked = true; return { revoked: true, deploymentBlocked: true }; }
|
|
restoreDeployKey() { this.state.keyRevoked = false; this.state.keyReadOnly = true; return { restored: true }; }
|
|
inventory(count = 20, partial = false) { return { workloads: Array.from({ length: count }, (_, index) => ({ id: `workload-${index + 1}`, classification: index === 1 ? "duplicate" : "active" })), partial, warnings: partial ? ["One scan root was unavailable"] : [] }; }
|
|
|
|
async publishRelease(version, options = {}) {
|
|
const binary = Buffer.from(options.binary || "MZ-forgeflow-acceptance-binary");
|
|
const name = `ForgeFlow-Portable-${version}-win-x64.exe`;
|
|
const checksum = crypto.createHash("sha256").update(binary).digest("hex");
|
|
const manifest = { version, draft: options.draft === true, assets: options.missingAsset ? [] : [{ name, sha256: options.badChecksum ? "0".repeat(64) : checksum }], provenance: { commitSha: options.commitSha || this.initialSha }, sbom: { bomFormat: "CycloneDX" } };
|
|
await fs.writeFile(path.join(this.paths.releases, `${version}.json`), JSON.stringify(manifest, null, 2));
|
|
if (!options.missingAsset) await fs.writeFile(path.join(this.paths.releases, name), binary);
|
|
return manifest;
|
|
}
|
|
|
|
async verifyRelease(version) {
|
|
const manifest = JSON.parse(await fs.readFile(path.join(this.paths.releases, `${version}.json`), "utf8"));
|
|
if (manifest.draft) throw new Error("Incomplete draft release rejected.");
|
|
const asset = manifest.assets[0];
|
|
if (!asset) throw new Error("Required release asset is missing.");
|
|
const binary = await fs.readFile(path.join(this.paths.releases, asset.name));
|
|
if (crypto.createHash("sha256").update(binary).digest("hex") !== asset.sha256) throw new Error("Release checksum mismatch.");
|
|
if (!manifest.provenance?.commitSha || manifest.sbom?.bomFormat !== "CycloneDX") throw new Error("Release provenance or SBOM is missing.");
|
|
return { verified: true, version, asset: asset.name };
|
|
}
|
|
}
|
|
|
|
module.exports = { ProductionAcceptanceHarness };
|