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;