137 lines
6.8 KiB
JavaScript
137 lines
6.8 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";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const archive = path.join(repoRoot, "release/blender-web-offline.tar.gz");
|
|
const reportRoot = path.join(repoRoot, "release/operations-reports");
|
|
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-rollback-runbook-"));
|
|
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
|
|
function readJson(file) {
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
}
|
|
|
|
function writeMetadata(release, metadata) {
|
|
fs.writeFileSync(path.join(release, "release-metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
|
|
}
|
|
|
|
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"));
|
|
}
|
|
|
|
function rollbackCompatibility(openedStorage, target) {
|
|
if (target.storage.indexedDbSchemaVersion < openedStorage.indexedDbSchemaVersion) {
|
|
return { status: "BLOCKED", reason: "INDEXEDDB_SCHEMA_DOWNGRADE_UNSUPPORTED" };
|
|
}
|
|
if (target.storage.opfsProjectManifestSchemaVersion !== openedStorage.opfsProjectManifestSchemaVersion) {
|
|
return { status: "BLOCKED", reason: "OPFS_REVERSE_MIGRATION_UNDECLARED" };
|
|
}
|
|
return { status: "READY", reason: null };
|
|
}
|
|
|
|
try {
|
|
const installRoot = path.join(workspace, "install");
|
|
const releases = path.join(installRoot, "releases");
|
|
const originStorage = path.join(workspace, "origin-storage", "projects", "project-a");
|
|
fs.mkdirSync(releases, { recursive: true });
|
|
fs.mkdirSync(originStorage, { recursive: true });
|
|
|
|
const extracted = path.join(workspace, "extracted");
|
|
fs.mkdirSync(extracted);
|
|
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", archive, "-C", extracted]);
|
|
const currentRelease = path.join(releases, "current-release");
|
|
fs.renameSync(path.join(extracted, "blender-web-offline"), currentRelease);
|
|
const currentMetadata = readJson(path.join(currentRelease, "release-metadata.json"));
|
|
|
|
const compatibleOld = path.join(releases, "compatible-old-release");
|
|
fs.cpSync(currentRelease, compatibleOld, { recursive: true });
|
|
const compatibleMetadata = structuredClone(currentMetadata);
|
|
compatibleMetadata.productVersion = "0.0.9-compatible";
|
|
compatibleMetadata.engineReleaseId = `${currentMetadata.engineReleaseId}-compatible-old`;
|
|
writeMetadata(compatibleOld, compatibleMetadata);
|
|
|
|
const incompatibleOld = path.join(releases, "incompatible-old-release");
|
|
fs.cpSync(currentRelease, incompatibleOld, { recursive: true });
|
|
const incompatibleMetadata = structuredClone(currentMetadata);
|
|
incompatibleMetadata.productVersion = "0.0.8-incompatible";
|
|
incompatibleMetadata.engineReleaseId = `${currentMetadata.engineReleaseId}-incompatible-old`;
|
|
incompatibleMetadata.storage.indexedDbSchemaVersion = Math.max(1, currentMetadata.storage.indexedDbSchemaVersion - 1);
|
|
writeMetadata(incompatibleOld, incompatibleMetadata);
|
|
|
|
switchCurrent(installRoot, path.basename(currentRelease));
|
|
const blend = Buffer.from("M6-17C persistent project content");
|
|
const project = {
|
|
schemaVersion: currentMetadata.storage.opfsProjectManifestSchemaVersion,
|
|
revision: 17,
|
|
bytes: blend.byteLength,
|
|
sha256: sha256(blend),
|
|
};
|
|
fs.writeFileSync(path.join(originStorage, "scene.blend"), blend);
|
|
fs.writeFileSync(path.join(originStorage, "scene.blend.meta.json"), `${JSON.stringify(project)}\n`);
|
|
const projectBytesBefore = fs.readFileSync(path.join(originStorage, "scene.blend"));
|
|
const manifestBefore = fs.readFileSync(path.join(originStorage, "scene.blend.meta.json"));
|
|
const currentBefore = fs.realpathSync(path.join(installRoot, "current"));
|
|
|
|
const openedStorage = {
|
|
indexedDbSchemaVersion: currentMetadata.storage.indexedDbSchemaVersion,
|
|
opfsProjectManifestSchemaVersion: currentMetadata.storage.opfsProjectManifestSchemaVersion,
|
|
};
|
|
const blocked = rollbackCompatibility(openedStorage, incompatibleMetadata);
|
|
assert.deepEqual(blocked, { status: "BLOCKED", reason: "INDEXEDDB_SCHEMA_DOWNGRADE_UNSUPPORTED" });
|
|
assert.equal(fs.realpathSync(path.join(installRoot, "current")), currentBefore, "blocked rollback changed current");
|
|
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend")), projectBytesBefore);
|
|
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend.meta.json")), manifestBefore);
|
|
|
|
const compatible = rollbackCompatibility(openedStorage, compatibleMetadata);
|
|
assert.deepEqual(compatible, { status: "READY", reason: null });
|
|
switchCurrent(installRoot, path.basename(compatibleOld));
|
|
assert.equal(fs.realpathSync(path.join(installRoot, "current")), compatibleOld);
|
|
assert.equal(readJson(path.join(installRoot, "current", "release-metadata.json")).engineReleaseId, compatibleMetadata.engineReleaseId);
|
|
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend")), projectBytesBefore);
|
|
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend.meta.json")), manifestBefore);
|
|
const reopened = readJson(path.join(originStorage, "scene.blend.meta.json"));
|
|
assert.equal(reopened.revision, project.revision);
|
|
assert.equal(reopened.sha256, sha256(fs.readFileSync(path.join(originStorage, "scene.blend"))));
|
|
assert.ok(fs.statSync(currentRelease).isDirectory(), "newer release was removed during rollback");
|
|
|
|
const opfsBlockedTarget = structuredClone(compatibleMetadata);
|
|
opfsBlockedTarget.storage.opfsProjectManifestSchemaVersion += 1;
|
|
assert.deepEqual(rollbackCompatibility(openedStorage, opfsBlockedTarget), {
|
|
status: "BLOCKED",
|
|
reason: "OPFS_REVERSE_MIGRATION_UNDECLARED",
|
|
});
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
task: "M6-17C",
|
|
status: "READY",
|
|
activeBefore: currentMetadata.engineReleaseId,
|
|
activeAfter: compatibleMetadata.engineReleaseId,
|
|
compatibleRollback: compatible,
|
|
blockedOldReader: blocked,
|
|
blockedOpfsMismatch: "OPFS_REVERSE_MIGRATION_UNDECLARED",
|
|
project: {
|
|
revisionBefore: project.revision,
|
|
revisionAfter: reopened.revision,
|
|
sha256Before: project.sha256,
|
|
sha256After: reopened.sha256,
|
|
preserved: true,
|
|
},
|
|
releases: { newerRetained: true, targetSwitchedAtomically: true },
|
|
};
|
|
fs.mkdirSync(reportRoot, { recursive: true });
|
|
fs.writeFileSync(path.join(reportRoot, "rollback.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
process.stdout.write(`rollback-runbook-ok compatible=ready idb-downgrade=blocked opfs-mismatch=blocked revision=${project.revision} project=${project.sha256}\n`);
|
|
}
|
|
finally {
|
|
fs.rmSync(workspace, { recursive: true, force: true });
|
|
}
|