192 lines
9.6 KiB
JavaScript
192 lines
9.6 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
createDeploymentHttpServer,
|
|
listenDeploymentHttpServer,
|
|
} from "./deployment-http-server.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const releaseRoot = path.join(repoRoot, "release");
|
|
const archive = path.join(releaseRoot, "blender-web-offline.tar.gz");
|
|
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
|
|
const sumsFile = path.join(releaseRoot, "SHA256SUMS.txt");
|
|
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-operations-rehearsal-"));
|
|
assert.equal(path.dirname(workspace), os.tmpdir());
|
|
assert.ok(path.basename(workspace).startsWith("blender-operations-rehearsal-"));
|
|
|
|
const records = [];
|
|
let failure = null;
|
|
let server = null;
|
|
let projectBefore = null;
|
|
let projectAfter = null;
|
|
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
const fileSha256 = (file) => sha256(fs.readFileSync(file));
|
|
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
|
|
async function run(id, command, action) {
|
|
const started = Date.now();
|
|
try {
|
|
const output = await action();
|
|
records.push({ id, command, exitCode: 0, durationMs: Date.now() - started, output });
|
|
return output;
|
|
}
|
|
catch (error) {
|
|
records.push({
|
|
id,
|
|
command,
|
|
exitCode: 1,
|
|
durationMs: Date.now() - started,
|
|
output: { error: error instanceof Error ? error.message : String(error) },
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function switchCurrent(installRoot, releaseName) {
|
|
const pending = path.join(installRoot, ".current.next");
|
|
fs.rmSync(pending, { force: true });
|
|
fs.symlinkSync(path.join("releases", releaseName), pending);
|
|
fs.renameSync(pending, path.join(installRoot, "current"));
|
|
}
|
|
|
|
async function validateInstalledHttp(installRoot) {
|
|
const bundle = path.join(installRoot, "current");
|
|
const contract = readJson(path.join(bundle, "deployment-contract.json"));
|
|
const engine = readJson(path.join(bundle, "app/engine-manifest.json"));
|
|
server = createDeploymentHttpServer({ root: path.join(bundle, "app"), contract });
|
|
const origin = await listenDeploymentHttpServer(server);
|
|
try {
|
|
const head = await fetch(`${origin}/`, { method: "HEAD" });
|
|
assert.equal(head.status, 200);
|
|
for (const [name, value] of Object.entries(contract.responseHeaders.allResponses)) {
|
|
assert.equal(head.headers.get(name), value);
|
|
}
|
|
const single = engine.variants.find((variant) => variant.id === "single");
|
|
const range = await fetch(new URL(single.resources.wasm.url, origin), { headers: { Range: "bytes=0-15" } });
|
|
assert.equal(range.status, 206);
|
|
assert.equal((await range.arrayBuffer()).byteLength, 16);
|
|
return { originMode: "loopback-http", headStatus: head.status, rangeStatus: range.status, releaseId: engine.releaseId };
|
|
}
|
|
finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
server = null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const delivery = path.join(workspace, "delivery");
|
|
const installRoot = path.join(workspace, "install");
|
|
const originStorage = path.join(workspace, "origin-storage", "projects", "rehearsal-project");
|
|
fs.mkdirSync(delivery);
|
|
fs.mkdirSync(installRoot);
|
|
|
|
await run("delivery-check", "sha256sum -c SHA256SUMS.txt && tar -tzf blender-web-offline.tar.gz", async () => {
|
|
for (const file of [archive, sourceArchive, sumsFile]) fs.copyFileSync(file, path.join(delivery, path.basename(file)));
|
|
const entries = Object.fromEntries(fs.readFileSync(path.join(delivery, "SHA256SUMS.txt"), "utf8").trim().split("\n").map((line) => {
|
|
const match = line.match(/^([a-f0-9]{64}) ([^/]+)$/);
|
|
assert.ok(match, `invalid checksum entry: ${line}`);
|
|
return [match[2], match[1]];
|
|
}));
|
|
assert.equal(fileSha256(path.join(delivery, path.basename(archive))), entries[path.basename(archive)]);
|
|
assert.equal(fileSha256(path.join(delivery, path.basename(sourceArchive))), entries[path.basename(sourceArchive)]);
|
|
const archiveEntries = execFileSync("tar", ["-tzf", path.join(delivery, path.basename(archive))], { encoding: "utf8" }).split("\n").filter(Boolean);
|
|
assert.ok(archiveEntries.length > 0);
|
|
for (const entry of archiveEntries) {
|
|
assert.ok(!entry.startsWith("/") && !entry.split("/").includes(".."));
|
|
assert.ok(entry === "blender-web-offline/" || entry.startsWith("blender-web-offline/"));
|
|
}
|
|
return { binarySha256: entries[path.basename(archive)], sourceSha256: entries[path.basename(sourceArchive)], archiveEntries: archiveEntries.length };
|
|
});
|
|
|
|
const installed = await run("deploy-install", "tar --no-same-owner --no-same-permissions -xzf ARCHIVE -C STAGE && mv STAGE RELEASE && mv -Tf .current.next current", async () => {
|
|
const releases = path.join(installRoot, "releases");
|
|
fs.mkdirSync(releases);
|
|
const staging = fs.mkdtempSync(path.join(installRoot, ".install."));
|
|
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", path.join(delivery, path.basename(archive)), "-C", staging]);
|
|
const releaseName = fileSha256(path.join(delivery, path.basename(archive)));
|
|
fs.renameSync(path.join(staging, "blender-web-offline"), path.join(releases, releaseName));
|
|
fs.rmdirSync(staging);
|
|
switchCurrent(installRoot, releaseName);
|
|
return { releaseName, currentTarget: fs.readlinkSync(path.join(installRoot, "current")), emptyInstallRoot: true };
|
|
});
|
|
|
|
await run("deploy-http", "HEAD / && GET Range:bytes=0-15 single/web_engine.wasm", () => validateInstalledHttp(installRoot));
|
|
|
|
await run("project-seed", "persist project revision and SHA-256 outside release directories", async () => {
|
|
fs.mkdirSync(originStorage, { recursive: true });
|
|
const bytes = Buffer.from("M6-17E rehearsal project bytes");
|
|
const manifest = { schemaVersion: 1, revision: 23, bytes: bytes.length, sha256: sha256(bytes) };
|
|
fs.writeFileSync(path.join(originStorage, "scene.blend"), bytes);
|
|
fs.writeFileSync(path.join(originStorage, "scene.blend.meta.json"), `${JSON.stringify(manifest)}\n`);
|
|
projectBefore = manifest;
|
|
return manifest;
|
|
});
|
|
|
|
const upgraded = await run("upgrade-switch", "stage NEXT_RELEASE && mv -Tf .current.next current", async () => {
|
|
const oldRelease = path.join(installRoot, "releases", installed.releaseName);
|
|
const nextName = `${installed.releaseName}-next`;
|
|
const nextRelease = path.join(installRoot, "releases", nextName);
|
|
fs.cpSync(oldRelease, nextRelease, { recursive: true });
|
|
const metadataPath = path.join(nextRelease, "release-metadata.json");
|
|
const metadata = readJson(metadataPath);
|
|
metadata.productVersion = `${metadata.productVersion}-rehearsal-next`;
|
|
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
switchCurrent(installRoot, nextName);
|
|
assert.equal(fileSha256(path.join(originStorage, "scene.blend")), projectBefore.sha256);
|
|
return { from: installed.releaseName, to: nextName, oldReleaseRetained: fs.existsSync(oldRelease) };
|
|
});
|
|
|
|
await run("upgrade-http", "HEAD / && GET Range:bytes=0-15 after upgrade", () => validateInstalledHttp(installRoot));
|
|
|
|
await run("rollback-switch", "preflight schemas && mv -Tf .current.next current", async () => {
|
|
const activeMetadata = readJson(path.join(installRoot, "current", "release-metadata.json"));
|
|
const targetMetadata = readJson(path.join(installRoot, "releases", installed.releaseName, "release-metadata.json"));
|
|
assert.equal(activeMetadata.storage.indexedDbSchemaVersion, targetMetadata.storage.indexedDbSchemaVersion);
|
|
assert.equal(activeMetadata.storage.opfsProjectManifestSchemaVersion, targetMetadata.storage.opfsProjectManifestSchemaVersion);
|
|
switchCurrent(installRoot, installed.releaseName);
|
|
projectAfter = readJson(path.join(originStorage, "scene.blend.meta.json"));
|
|
assert.deepEqual(projectAfter, projectBefore);
|
|
assert.equal(fileSha256(path.join(originStorage, "scene.blend")), projectBefore.sha256);
|
|
return { from: upgraded.to, to: installed.releaseName, projectRevision: projectAfter.revision, projectSha256: projectAfter.sha256 };
|
|
});
|
|
|
|
await run("rollback-http", "HEAD / && GET Range:bytes=0-15 after rollback", () => validateInstalledHttp(installRoot));
|
|
}
|
|
catch (error) {
|
|
failure = error;
|
|
}
|
|
finally {
|
|
if (server) await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(workspace, { recursive: true, force: true });
|
|
}
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
task: "M6-17E",
|
|
status: failure ? "FAILED" : "READY",
|
|
generatedAt: new Date().toISOString(),
|
|
freshTemporaryRoot: true,
|
|
oneContinuousWorkspace: true,
|
|
workspaceCleaned: !fs.existsSync(workspace),
|
|
project: projectBefore && projectAfter ? {
|
|
revisionBefore: projectBefore.revision,
|
|
revisionAfter: projectAfter.revision,
|
|
sha256Before: projectBefore.sha256,
|
|
sha256After: projectAfter.sha256,
|
|
preserved: projectBefore.revision === projectAfter.revision && projectBefore.sha256 === projectAfter.sha256,
|
|
} : null,
|
|
records,
|
|
};
|
|
const reportRoot = path.join(releaseRoot, "operations-reports");
|
|
fs.mkdirSync(reportRoot, { recursive: true });
|
|
const reportPath = path.join(reportRoot, "rehearsal.json");
|
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
fs.writeFileSync(`${reportPath}.sha256`, `${fileSha256(reportPath)} ${path.basename(reportPath)}\n`);
|
|
process.stdout.write(`operations-rehearsal status=${report.status} records=${records.length} project=${report.project?.preserved === true ? "preserved" : "unverified"} cleanup=${report.workspaceCleaned}\n`);
|
|
if (failure) throw failure;
|