Files
gstack/runtime/upgrade.js
T

371 lines
14 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
import { atomicWriteJson, pathExists, readJson, renameWithRetry } from "./storage.js";
import {
assertManagedHome,
ensureManagedHome,
ensureManagedRuntimeDirectory,
recoverRuntimeTransactionUnlocked,
withRuntimeLifecycleLock,
} from "./managed-home.js";
import { errorWithCode as upgradeError } from "./errors.js";
import { currentIsoTimestamp as isoNow } from "./time.js";
export async function stageUpgrade(options) {
const home = path.resolve(options.home);
return withRuntimeLifecycleLock(home, async () => {
await ensureManagedHome(home, options);
await recoverRuntimeTransactionUnlocked(home);
// Consuming input is reserved for the installer's validated scratch.
// Public upgrades always preserve and copy caller-owned sources.
const { consumeInstallerScratch: _ignored, ...publicOptions } = options;
return stageUpgradeUnlocked({ ...publicOptions, home });
}, options);
}
/**
* Internal transaction primitive used by the managed installer while it holds
* the one lifecycle lock. Callers may update stable launchers/manifest in
* beforeActivate and restore their snapshot in onRollback.
*/
export async function stageUpgradeUnlocked(options) {
const { home, sourceDir } = options;
const version = validateVersion(options.version);
const paths = resolveRuntimePaths({ home });
const source = path.resolve(sourceDir);
await validateStageSource(source);
const consumeSource = options.consumeInstallerScratch === true;
if (consumeSource) {
await ensureManagedRuntimeDirectory(home, paths.tmp);
await assertInstallerScratchSource(source, paths.tmp);
}
await ensureManagedRuntimeDirectory(home, paths.versions);
await recoverPendingUpgradeUnlocked(paths, options);
const previousExists = await pathExists(paths.versionPointer);
const previous = await readJson(paths.versionPointer, emptyPointer());
validatePointer(previous);
const destination = assertPathInside(paths.versions, path.join(paths.versions, version));
let staged = false;
if (!(await pathExists(destination))) {
const stage = assertPathInside(
paths.versions,
path.join(paths.versions, `.stage-${version}-${randomUUID()}`),
);
try {
if (consumeSource) await renameWithRetry(source, stage);
else await copyDirectory(source, stage);
await atomicWriteJson(path.join(stage, ".gstack-version.json"), {
schemaVersion: 2,
version,
stagedAt: isoNow(options.now),
}, { mode: 0o644 });
await assertTreeContainsNoLinks(stage);
if (options.verify) await options.verify(stage);
await renameWithRetry(stage, destination);
staged = true;
} catch (error) {
await fs.rm(stage, { recursive: true, force: true }).catch(() => {});
throw error;
}
} else {
if (!(await isRealDirectory(destination))) {
throw upgradeError(`Version destination is not a real directory: ${version}`, "UPGRADE_DESTINATION_INVALID");
}
await assertTreeContainsNoLinks(destination);
if (options.verify) await options.verify(destination);
}
const lastKnownGood = previous.status === "active" && previous.current && previous.current !== version
? previous.current
: previous.lastKnownGood ?? null;
const active = {
schemaVersion: 2,
status: "active",
current: version,
lastKnownGood,
activatedAt: isoNow(options.now),
verifiedAt: isoNow(options.now),
};
try {
// Health runs while the old active pointer remains visible. A candidate is
// never published as `pending`, so concurrent launchers cannot execute it.
if (options.healthCheck) await options.healthCheck(destination);
if (options.beforeActivate) await options.beforeActivate({
active,
previous,
previousExists,
destination,
staged,
paths,
});
await atomicWriteJson(paths.versionPointer, active, { mode: 0o600 });
if (options.afterActivate) await options.afterActivate({
active,
previous,
previousExists,
destination,
staged,
paths,
});
return { pointer: active, path: destination, staged, consumedSource: staged && consumeSource };
} catch (cause) {
const rollbackErrors = [];
let pointerRollbackError = null;
try {
if (previousExists) {
await atomicWriteJson(paths.versionPointer, previous, { mode: 0o600 });
} else {
await atomicWriteJson(paths.versionPointer, {
...emptyPointer(),
status: "rolled_back",
failedVersion: version,
rolledBackAt: isoNow(options.now),
}, { mode: 0o600 });
}
} catch (error) {
pointerRollbackError = error;
rollbackErrors.push(error);
}
try {
if (options.onRollback) await options.onRollback({
active,
previous,
previousExists,
destination,
staged,
paths,
cause,
pointerRollbackError,
});
} catch (error) {
rollbackErrors.push(error);
}
if (staged) {
try {
await fs.rm(destination, { recursive: true, force: true });
} catch (error) {
rollbackErrors.push(error);
}
}
const error = upgradeError(`Upgrade ${version} failed health checks and was rolled back`, "UPGRADE_ROLLED_BACK", cause);
if (rollbackErrors.length === 1) error.rollbackError = rollbackErrors[0];
else if (rollbackErrors.length > 1) error.rollbackError = new AggregateError(rollbackErrors, "Runtime rollback was incomplete");
throw error;
}
}
export async function recoverPendingUpgrade(home, options = {}) {
const resolved = path.resolve(home);
return withRuntimeLifecycleLock(resolved, async () => {
await assertManagedHome(resolved, options);
await recoverRuntimeTransactionUnlocked(resolved);
return recoverPendingUpgradeUnlocked(resolveRuntimePaths({ home: resolved }), options);
}, options);
}
export async function recoverPendingUpgradeUnlocked(paths, options = {}) {
const pointer = await readJson(paths.versionPointer, emptyPointer());
validatePointer(pointer);
if (pointer.status !== "pending") return { recovered: false, pointer };
const fallback = pointer.lastKnownGood;
const fallbackPath = fallback
? assertPathInside(paths.versions, path.join(paths.versions, validateVersion(fallback)))
: null;
const fallbackExists = fallbackPath && await isRealDirectory(fallbackPath);
const recovered = fallbackExists
? {
schemaVersion: 2,
status: "active",
current: fallback,
lastKnownGood: null,
recoveredFrom: pointer.current,
recoveredAt: isoNow(options.now),
}
: {
...emptyPointer(),
status: "rolled_back",
failedVersion: pointer.current,
recoveredAt: isoNow(options.now),
};
await atomicWriteJson(paths.versionPointer, recovered, { mode: 0o600 });
return { recovered: true, pointer: recovered };
}
export async function rollbackUpgrade(home, options = {}) {
const resolved = path.resolve(home);
return withRuntimeLifecycleLock(resolved, async () => {
await assertManagedHome(resolved, options);
await recoverRuntimeTransactionUnlocked(resolved);
const paths = resolveRuntimePaths({ home: resolved });
const recovered = await recoverPendingUpgradeUnlocked(paths, options);
const pointer = recovered.pointer;
if (!pointer.lastKnownGood) {
throw upgradeError("No last-known-good version is available", "NO_ROLLBACK_VERSION");
}
const fallbackVersion = validateVersion(pointer.lastKnownGood);
const fallbackPath = assertPathInside(paths.versions, path.join(paths.versions, fallbackVersion));
if (!(await isRealDirectory(fallbackPath))) {
throw upgradeError(`Last-known-good version is missing: ${fallbackVersion}`, "ROLLBACK_VERSION_MISSING");
}
await assertTreeContainsNoLinks(fallbackPath);
if (options.healthCheck) await options.healthCheck(fallbackPath);
const rolledBack = {
schemaVersion: 2,
status: "active",
current: fallbackVersion,
lastKnownGood: pointer.current ?? null,
rolledBackFrom: pointer.current ?? null,
rolledBackAt: isoNow(options.now),
};
await atomicWriteJson(paths.versionPointer, rolledBack, { mode: 0o600 });
return rolledBack;
}, options);
}
export async function activeVersion(home, options = {}) {
const recovered = await recoverPendingUpgrade(home, options);
return recovered.pointer;
}
export async function uninstallRuntime(home, options = {}) {
const resolved = path.resolve(home);
return withRuntimeLifecycleLock(resolved, async () => {
await assertManagedHome(resolved, options);
await recoverRuntimeTransactionUnlocked(resolved);
if (options.purge) {
return purgeManagedHomeUnlocked(resolved);
}
const paths = resolveRuntimePaths({ home: resolved });
await fs.rm(paths.versions, { recursive: true, force: true });
return { purged: false, preservedState: true, home: resolved };
}, options);
}
export async function purgeManagedHomeUnlocked(home) {
const resolved = path.resolve(home);
const ownership = await assertManagedHome(resolved);
const preexisting = new Set(ownership.sentinel.preexistingTopLevel ?? []);
const managedEntries = new Set([
".gstack-managed-home.json",
".gstack-runtime-transaction.json",
"bin",
"config.json",
"locks",
"migration.json",
"plans",
"projects",
"runtime-install.json",
"secrets.json",
"tmp",
"versions",
]);
const present = await fs.readdir(resolved);
const preserved = present.filter((entry) => !managedEntries.has(entry) || preexisting.has(entry));
const quarantine = `${resolved}.purge-${process.pid}-${randomUUID()}`;
await fs.mkdir(quarantine, { mode: 0o700 });
const moved = [];
try {
for (const entry of present.filter((name) => managedEntries.has(name) && !preexisting.has(name))) {
const source = assertPathInside(resolved, path.join(resolved, entry));
const destination = assertPathInside(quarantine, path.join(quarantine, entry));
await renameWithRetry(source, destination);
moved.push({ source, destination });
}
await fs.rm(quarantine, { recursive: true, force: true });
await fs.rmdir(resolved).catch((error) => {
if (error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") throw error;
});
return { purged: true, home: resolved, preserved };
} catch (error) {
for (const item of moved.reverse()) {
await renameWithRetry(item.destination, item.source).catch(() => {});
}
await fs.rmdir(quarantine).catch(() => {});
throw error;
}
}
function validateVersion(value) {
if (typeof value !== "string" || !/^[0-9A-Za-z][0-9A-Za-z._-]{0,79}$/.test(value)) {
throw new TypeError("Version must contain only letters, numbers, dots, underscores, or hyphens");
}
return value;
}
function validatePointer(pointer) {
const validStatuses = new Set(["inactive", "pending", "active", "rolled_back"]);
if (!pointer || pointer.schemaVersion !== 2 || !validStatuses.has(pointer.status)) {
throw upgradeError("Managed version pointer is missing or unsupported", "UPGRADE_POINTER_INVALID");
}
if (pointer.current != null) validateVersion(pointer.current);
if (pointer.lastKnownGood != null) validateVersion(pointer.lastKnownGood);
}
async function validateStageSource(source) {
const stat = await fs.lstat(source).catch((error) => {
if (error?.code === "ENOENT") throw upgradeError(`Upgrade source does not exist: ${source}`, "UPGRADE_SOURCE_INVALID", error);
throw error;
});
if (stat.isSymbolicLink() || !stat.isDirectory()) {
throw upgradeError("Upgrade source must be a real directory, not a symlink", "UPGRADE_SOURCE_INVALID");
}
if ((await fs.readdir(source)).length === 0) {
throw upgradeError("Upgrade source must not be empty", "UPGRADE_SOURCE_INVALID");
}
await assertTreeContainsNoLinks(source);
}
async function copyDirectory(source, destination) {
await fs.cp(source, destination, {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
verbatimSymlinks: true,
});
}
async function assertInstallerScratchSource(source, tmpRoot) {
const [physicalSource, physicalTmp] = await Promise.all([
fs.realpath(source),
fs.realpath(tmpRoot),
]);
if (path.dirname(physicalSource) !== physicalTmp || !path.basename(physicalSource).startsWith("install-")) {
throw upgradeError("Only a direct installer-owned scratch may be consumed", "UPGRADE_SOURCE_CONSUME_INVALID");
}
const stat = await fs.lstat(physicalSource);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw upgradeError("Installer scratch must be a real directory", "UPGRADE_SOURCE_CONSUME_INVALID");
}
}
async function assertTreeContainsNoLinks(root) {
const pending = [root];
while (pending.length > 0) {
const current = pending.pop();
const stat = await fs.lstat(current);
if (stat.isSymbolicLink()) {
throw upgradeError(`Upgrade source contains a symlink: ${current}`, "UPGRADE_SOURCE_INVALID");
}
if (stat.isDirectory()) {
for (const child of await fs.readdir(current)) pending.push(path.join(current, child));
} else if (!stat.isFile()) {
throw upgradeError(`Upgrade source contains an unsupported entry: ${current}`, "UPGRADE_SOURCE_INVALID");
}
}
}
async function isRealDirectory(directory) {
const stat = await fs.lstat(directory).catch(() => null);
return Boolean(stat?.isDirectory() && !stat.isSymbolicLink());
}
function emptyPointer() {
return { schemaVersion: 2, status: "inactive", current: null, lastKnownGood: null };
}