Advance M7 workflows and release operations
This commit is contained in:
136
tools/web/check-rollback-runbook.mjs
Normal file
136
tools/web/check-rollback-runbook.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user