openclaw / src /fleet /backup.runtime.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
76289e7 verified
Raw History Blame Contribute Delete
30.3 kB
import { randomUUID } from "node:crypto";
import { createWriteStream, type Stats } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import * as tar from "tar";
import {
ARCHIVE_LIMIT_ERROR_CODE,
ArchiveFormatError,
ArchiveLimitError,
ArchiveSecurityError,
extractArchive,
} from "../infra/archive.js";
import { createBackupVolatileStatCache } from "../infra/backup-volatile-stat-cache.js";
import {
getPublishFileExclusiveFailureDetails,
publishFileNoClobber,
} from "../infra/directory-durability.js";
import { formatErrorMessage as errorMessage } from "../infra/errors.js";
import { root as fsSafeRoot } from "../infra/fs-safe.js";
import { isPathInside } from "../infra/path-guards.js";
import {
cellAuthSecretDir,
cellNetworkName,
FLEET_ATTEMPT_LABEL,
validateCellContainerProfile,
type CellContainerProfile,
} from "./cell-profile.js";
import type { FleetContainerRuntime } from "./containers.runtime.js";
import type { FleetCellRecord } from "./registry.js";
import {
assertManagedInspection,
assertManagedNetwork,
buildProfileBaseFromInspection,
prepareCellConfig,
prepareCellDirectories,
requireInspectedAttemptId,
requireInspectedGatewayToken,
resolveContainerUser,
resolvePurgeTarget,
verifyReplacementHealthy,
type HostIdentity,
} from "./service-support.runtime.js";
const DEFAULT_FLEET_BACKUP_MAX_BYTES = 16 * 1024 ** 3;
// Byte caps alone do not stop metadata-only archive bombs: millions of empty
// files stay under --max-bytes but can exhaust host inodes during extraction.
const FLEET_BACKUP_MAX_ENTRIES = 1_000_000;
const MANIFEST_MAX_BYTES = 4 * 1024 * 1024;
// Well under the 5-minute lease TTL so a stalled archive stream cannot outlive
// the lease by more than one probe interval before the backup aborts.
const BACKUP_LEASE_PROBE_INTERVAL_MS = 30_000;
const RESTORE_VERIFY_TIMEOUT_MS = 60_000;
const RESTORE_VERIFY_POLL_MS = 1_000;
const RESTORE_EXTRACT_TIMEOUT_MS = 30 * 60_000;
type FleetBackupManifest = {
schemaVersion: 1;
kind: "openclaw-fleet-cell-backup";
tenant: string;
createdAt: string;
hostPort: number;
image: string;
runtime: "docker" | "podman";
};
type FleetBackupResult = {
tenant: string;
archivePath: string;
fileCount: number;
skippedSymlinks: number;
skippedSpecial: number;
note: string;
};
type FleetRestoreResult = {
tenant: string;
archivePath: string;
token: string;
tokenNote: string;
started: boolean;
url: string;
};
function timestampBasename(tenant: string, nowMs: number): string {
const stamp = new Date(nowMs)
.toISOString()
.replaceAll(":", "-")
.replace(/\.\d{3}Z$/u, "Z");
return `openclaw-fleet-backup-${tenant}-${stamp}.tgz`;
}
async function resolveOutputPath(out: string | undefined, basename: string): Promise<string> {
if (!out) {
return path.resolve(process.cwd(), basename);
}
const resolved = path.resolve(out);
if (out.endsWith(path.sep) || out.endsWith("/") || out.endsWith("\\")) {
return path.join(resolved, basename);
}
try {
return (await fs.stat(resolved)).isDirectory() ? path.join(resolved, basename) : resolved;
} catch {
return resolved;
}
}
async function canonicalizeForContainment(targetPath: string): Promise<string> {
const resolved = path.resolve(targetPath);
const suffix: string[] = [];
let probe = resolved;
for (;;) {
try {
const real = await fs.realpath(probe);
return path.join(real, ...suffix.toReversed());
} catch {
const parent = path.dirname(probe);
if (parent === probe) {
return resolved;
}
suffix.push(path.basename(probe));
probe = parent;
}
}
}
function remapArchivePath(
entryPath: string,
manifestPath: string,
dataTarget: string,
authTarget: string,
): string {
const resolved = path.resolve(entryPath);
if (resolved === manifestPath) {
return "manifest.json";
}
if (isPathInside(dataTarget, resolved)) {
const relative = path.relative(dataTarget, resolved).split(path.sep).join(path.posix.sep);
return relative ? path.posix.join("data", relative) : "data";
}
if (isPathInside(authTarget, resolved)) {
const relative = path.relative(authTarget, resolved).split(path.sep).join(path.posix.sep);
return relative ? path.posix.join("auth", relative) : "auth";
}
throw new Error(`Fleet backup encountered a path outside the cell roots: ${entryPath}`);
}
export async function backupFleetCell(params: {
record: FleetCellRecord;
stateDir: string;
containers: FleetContainerRuntime;
now: () => number;
checkpoint: () => void;
out?: string;
maxBytes?: number;
maxEntries?: number;
}): Promise<FleetBackupResult> {
await params.containers.assertLocal(params.record.runtime);
const inspection = await params.containers.inspect(
params.record.runtime,
params.record.containerName,
);
if (inspection.kind === "unavailable") {
throw new Error(
`Cannot inspect ${params.record.runtime} container for tenant ${params.record.tenantId}: ${inspection.error}`,
);
}
if (inspection.kind === "ok") {
assertManagedInspection(params.record, inspection);
if (inspection.running) {
throw new Error(
`Fleet cell ${params.record.tenantId} is running; stop it first (openclaw fleet stop ${params.record.tenantId}) so SQLite state is captured consistently.`,
);
}
}
const dataTarget = await resolvePurgeTarget(
path.join(params.stateDir, "fleet", "cells"),
params.record.dataDir,
params.record.tenantId,
);
if (!dataTarget) {
throw new Error(`Fleet cell ${params.record.tenantId} has no cell data to back up.`);
}
const authTarget = await resolvePurgeTarget(
path.join(params.stateDir, "fleet", "auth-profile-secrets"),
cellAuthSecretDir(params.stateDir, params.record.tenantId),
params.record.tenantId,
);
// Refuse auth-less backups: restore requires the auth tree, and an archive
// without it could later replace a cell's auth-profile encryption keys with
// an empty directory.
if (!authTarget) {
throw new Error(
`Fleet cell ${params.record.tenantId} has no auth-secret directory to back up.`,
);
}
const nowMs = params.now();
const archivePath = await resolveOutputPath(
params.out,
timestampBasename(params.record.tenantId, nowMs),
);
const canonicalOutput = await canonicalizeForContainment(archivePath);
const roots = [dataTarget, authTarget];
if (roots.some((root) => isPathInside(root, canonicalOutput))) {
throw new Error(
"Fleet backup output must not be written inside the cell data or auth directory.",
);
}
try {
await fs.lstat(archivePath);
throw new Error(`Refusing to overwrite existing fleet backup archive: ${archivePath}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
await fs.mkdir(path.dirname(archivePath), { recursive: true });
const tempArchivePath = `${archivePath}.${randomUUID()}.tmp`;
const tempRoot = await fs.realpath(os.tmpdir());
const tempDir = await fs.mkdtemp(path.join(tempRoot, "openclaw-fleet-backup-"));
const manifestPath = path.join(tempDir, "manifest.json");
const manifest: FleetBackupManifest = {
schemaVersion: 1,
kind: "openclaw-fleet-cell-backup",
tenant: params.record.tenantId,
createdAt: new Date(nowMs).toISOString(),
hostPort: params.record.hostPort,
image: params.record.image,
runtime: params.record.runtime,
};
let fileCount = 0;
let skippedSymlinks = 0;
let skippedSpecial = 0;
let totalBytes = 0;
let totalEntries = 0;
let exceeded = false;
let tooManyEntries = false;
let leaseLost = false;
let unrestorablePath: string | undefined;
let lastLeaseProbeMs = params.now();
const maxBytes = params.maxBytes ?? DEFAULT_FLEET_BACKUP_MAX_BYTES;
const maxEntries = params.maxEntries ?? FLEET_BACKUP_MAX_ENTRIES;
try {
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
const filter = (entryPath: string, stat: Stats | tar.ReadEntry): boolean => {
if (exceeded || tooManyEntries || leaseLost) {
return false;
}
// Probe the mutation lease during long archive streams so a lost lease
// (another operation could start the cell mid-read) aborts the backup
// instead of publishing a possibly-torn archive. node-tar filters run
// from async callbacks, so record the loss and throw after tar settles.
if (params.now() - lastLeaseProbeMs >= BACKUP_LEASE_PROBE_INTERVAL_MS) {
lastLeaseProbeMs = params.now();
try {
params.checkpoint();
} catch {
leaseLost = true;
return false;
}
}
const type = "type" in stat ? stat.type : undefined;
const isSymlink = "isSymbolicLink" in stat ? stat.isSymbolicLink() : type === "SymbolicLink";
if (isSymlink) {
skippedSymlinks += 1;
return false;
}
const isFile = "isFile" in stat ? stat.isFile() : type === "File";
const isDirectory = "isDirectory" in stat ? stat.isDirectory() : type === "Directory";
if (!isFile && !isDirectory) {
skippedSpecial += 1;
return false;
}
// Validate and budget with the same path rules restore applies, so every
// archive backup accepts stays restorable: same segment accounting, and
// no names (e.g. literal backslashes) restore would reject as traversal.
const archivedPath = remapArchivePath(entryPath, manifestPath, dataTarget, authTarget);
if (!isAllowedRestorePath(archivedPath)) {
unrestorablePath = unrestorablePath ?? entryPath;
return false;
}
totalEntries += archivedPath.split("/").filter(Boolean).length;
if (totalEntries > maxEntries) {
tooManyEntries = true;
return false;
}
if (isFile) {
totalBytes += stat.size;
fileCount += 1;
if (totalBytes > maxBytes) {
exceeded = true;
return false;
}
}
return true;
};
await pipeline(
tar.c(
{
gzip: true,
portable: true,
preservePaths: true,
statCache: createBackupVolatileStatCache(() => false),
filter,
onWriteEntry: (entry) => {
entry.path = remapArchivePath(entry.path, manifestPath, dataTarget, authTarget);
},
},
[manifestPath, dataTarget, authTarget],
),
// Finish streaming into a same-directory temp path before publication;
// filesystems without hard-link support still need a non-atomic copy.
createWriteStream(tempArchivePath, { flags: "wx", mode: 0o600 }),
);
// A single large file can stream past the lease TTL without a filter
// callback, so validate lease ownership once more before the archive is
// declared good; a lost lease means the cell may have run mid-read.
try {
params.checkpoint();
} catch {
leaseLost = true;
}
if (leaseLost) {
throw new Error(
`Fleet backup for ${params.record.tenantId} lost its operation lease; the partial archive was discarded. Retry the backup.`,
);
}
if (exceeded) {
throw new Error(
`Fleet backup exceeds the ${maxBytes}-byte limit; raise --max-bytes or reduce the cell data.`,
);
}
if (tooManyEntries) {
throw new Error(
`Fleet backup exceeds the ${maxEntries}-entry limit; reduce the number of files in the cell data.`,
);
}
if (unrestorablePath !== undefined) {
throw new Error(
`Fleet backup refuses a file name its restore path rules would reject: ${unrestorablePath}. Rename the file inside the cell and retry.`,
);
}
try {
await publishFileNoClobber(tempArchivePath, archivePath, {
strategy: "link-or-copy",
durability: "degrade",
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
throw new Error(`Refusing to overwrite existing fleet backup archive: ${archivePath}`, {
cause: error,
});
}
if (getPublishFileExclusiveFailureDetails(error)?.cleanup === "unknown") {
throw new Error(
`Fleet backup publication failed: ${errorMessage(error)}. A partial archive may remain at ${archivePath}; inspect or remove it before retrying.`,
{ cause: error },
);
}
throw error;
}
return {
tenant: params.record.tenantId,
archivePath,
fileCount,
skippedSymlinks,
skippedSpecial,
note: "Archive contains tenant state and auth secrets; store it like a credential.",
};
} finally {
await fs.rm(tempArchivePath, { force: true }).catch(() => undefined);
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
}
function isAllowedRestorePath(rawPath: string): boolean {
// Backup writes canonical POSIX names; restore receives fs-safe's canonical
// paths. Reject raw aliases here when validating backup source names.
if (rawPath.includes("\\")) {
return false;
}
const normalized = path.posix.normalize(rawPath);
if (normalized !== rawPath || normalized.startsWith("/") || normalized.startsWith("../")) {
return false;
}
return (
normalized === "manifest.json" ||
normalized === "data" ||
normalized.startsWith("data/") ||
normalized === "auth" ||
normalized.startsWith("auth/")
);
}
// Extraction runs as the invoking user, so a root-invoked restore must repair
// ownership for whichever identity the cell container actually runs as: the
// explicit non-root user mapping when one exists, else the image default
// (uid 1000). Rootless mappings (uid 0) keep root ownership, which the user
// namespace translates to the daemon user.
function resolveRestoreOwner(
hostIdentity: HostIdentity | undefined,
containerUser: CellContainerProfile["containerUser"],
): { uid: number; gid: number } | undefined {
if (hostIdentity?.uid !== 0) {
return undefined;
}
if (!containerUser) {
return { uid: 1000, gid: 1000 };
}
return containerUser.uid > 0 ? { uid: containerUser.uid, gid: containerUser.gid } : undefined;
}
// Lease liveness during this walk comes from withFleetCellOperation's interval
// heartbeat (60s, well under the 5-minute TTL); per-entry checkpoints would add
// a SQLite write per file for a stall-only risk the v1 lease design accepts.
async function chownTree(root: string, owner: { uid: number; gid: number }): Promise<void> {
await fs.chown(root, owner.uid, owner.gid);
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Refusing to chown symlink in restored fleet data: ${entryPath}`);
}
if (entry.isDirectory()) {
await chownTree(entryPath, owner);
} else {
await fs.chown(entryPath, owner.uid, owner.gid);
}
}
}
export async function restoreFleetCell(params: {
record: FleetCellRecord;
stateDir: string;
containers: FleetContainerRuntime;
fetchImpl: typeof fetch;
now: () => number;
sleep: (ms: number) => Promise<void>;
checkpoint: () => void;
generateToken: () => string;
generateAttemptId: () => string;
hostIdentity: HostIdentity | undefined;
selinuxRelabel: boolean;
from: string;
force?: boolean;
maxBytes?: number;
maxEntries?: number;
}): Promise<FleetRestoreResult> {
await params.containers.assertLocal(params.record.runtime);
const archivePath = path.resolve(params.from);
const archiveStat = await fs.lstat(archivePath);
if (!archiveStat.isFile() || archiveStat.isSymbolicLink()) {
throw new Error(`Fleet restore archive must be a regular file: ${archivePath}`);
}
const canonicalArchive = await fs.realpath(archivePath);
const restoreRoots = await Promise.all([
canonicalizeForContainment(params.record.dataDir),
canonicalizeForContainment(cellAuthSecretDir(params.stateDir, params.record.tenantId)),
]);
if (restoreRoots.some((root) => isPathInside(root, canonicalArchive))) {
throw new Error(
"Fleet restore archive must not be stored inside the cell data or auth directory.",
);
}
const inspectionResult = await params.containers.inspect(
params.record.runtime,
params.record.containerName,
);
if (inspectionResult.kind === "missing") {
throw new Error(
`Fleet cell container is missing for ${params.record.tenantId}; remove the stale registration without purging data (openclaw fleet rm ${params.record.tenantId} --force), recreate a stopped cell with the intended image (openclaw fleet create ${params.record.tenantId} --no-start --image <image>), then retry fleet restore.`,
);
}
const inspection = assertManagedInspection(params.record, inspectionResult);
if (inspection.running && !params.force) {
throw new Error(
`Fleet cell ${params.record.tenantId} is running; pass --force to stop it and replace its state.`,
);
}
const wasRunning = inspection.running;
requireInspectedGatewayToken(inspection, "restore");
requireInspectedAttemptId(inspection, "restore");
const tempRoot = path.join(params.stateDir, "fleet", "restore-tmp");
await fs.mkdir(tempRoot, { recursive: true, mode: 0o700 });
const tempDir = await fs.mkdtemp(path.join(tempRoot, `${params.record.tenantId}-`));
await fs.chmod(tempDir, 0o700);
let preserveTemp = false;
let stoppedForRestore = false;
let containerRemoved = false;
let previousDisplaced = false;
let stateSwapped = false;
// Captured outside the try so the failure path can recognize the replacement
// generation by attempt label.
let replacementAttemptId = "";
try {
let invalidArchive = false;
let totalPathComponents = 0;
let tooManyPathComponents = false;
const maxBytes = params.maxBytes ?? DEFAULT_FLEET_BACKUP_MAX_BYTES;
const maxEntries = params.maxEntries ?? FLEET_BACKUP_MAX_ENTRIES;
try {
await extractArchive({
archivePath,
destDir: tempDir,
kind: "tar",
tarGzip: true,
timeoutMs: RESTORE_EXTRACT_TIMEOUT_MS,
entryModes: "clamp",
entryFilter: (entry) => {
// Preserve Fleet's aggregate component budget in addition to the
// per-entry preflight enforced by maxEntryPathComponents.
totalPathComponents += entry.path.split("/").filter(Boolean).length;
if (totalPathComponents > maxEntries) {
tooManyPathComponents = true;
return "skip";
}
if (
(entry.kind !== "file" && entry.kind !== "directory") ||
!isAllowedRestorePath(entry.path)
) {
invalidArchive = true;
return "skip";
}
return "extract";
},
onFiltered: "reject-archive",
limits: {
// The caller already pinned this exact archive path and size; setting
// the observed size avoids fs-safe's smaller generic upload default.
maxArchiveBytes: Math.max(1, archiveStat.size),
maxEntries,
maxExtractedBytes: maxBytes,
maxEntryBytes: maxBytes,
maxEntryPathComponents: maxEntries,
},
});
} catch (error) {
if (
tooManyPathComponents ||
(error instanceof ArchiveLimitError &&
(error.code === ARCHIVE_LIMIT_ERROR_CODE.ENTRY_COUNT_EXCEEDS_LIMIT ||
error.code === ARCHIVE_LIMIT_ERROR_CODE.ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT))
) {
throw new Error(
`Fleet restore exceeds the ${maxEntries}-entry limit; the archive is not a usable fleet cell backup.`,
{ cause: error },
);
}
if (
error instanceof ArchiveLimitError &&
(error.code === ARCHIVE_LIMIT_ERROR_CODE.EXTRACTED_SIZE_EXCEEDS_LIMIT ||
error.code === ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT)
) {
throw new Error(
`Fleet restore exceeds the ${maxBytes}-byte limit; raise --max-bytes or use a smaller archive.`,
{ cause: error },
);
}
if (
invalidArchive ||
error instanceof ArchiveSecurityError ||
error instanceof ArchiveFormatError
) {
throw new Error("Archive is not a fleet cell backup or was tampered with.", {
cause: error,
});
}
throw error;
}
const safeRoot = await fsSafeRoot(tempDir, {
symlinks: "reject",
hardlinks: "reject",
maxBytes: MANIFEST_MAX_BYTES,
nonBlockingRead: true,
});
let manifest: unknown;
try {
manifest = JSON.parse((await safeRoot.read("manifest.json")).buffer.toString("utf8"));
} catch (error) {
throw new Error("Archive is not a fleet cell backup or was tampered with.", {
cause: error,
});
}
if (
typeof manifest !== "object" ||
manifest === null ||
!("kind" in manifest) ||
manifest.kind !== "openclaw-fleet-cell-backup" ||
!("schemaVersion" in manifest) ||
manifest.schemaVersion !== 1 ||
!("tenant" in manifest) ||
typeof manifest.tenant !== "string"
) {
throw new Error("Archive is not a fleet cell backup or was tampered with.");
}
if (manifest.tenant !== params.record.tenantId) {
throw new Error(
`Backup archive belongs to tenant ${manifest.tenant}; refusing to restore it into ${params.record.tenantId}.`,
);
}
const extractedData = path.join(tempDir, "data");
const dataStat = await fs.lstat(extractedData);
if (!dataStat.isDirectory() || dataStat.isSymbolicLink()) {
throw new Error("Archive is not a fleet cell backup or was tampered with.");
}
// The auth tree is mandatory: accepting an auth-less archive would swap the
// cell's auth-profile encryption keys for an empty directory and then delete
// the displaced originals on successful cleanup.
const extractedAuth = path.join(tempDir, "auth");
let authStat: Awaited<ReturnType<typeof fs.lstat>>;
try {
authStat = await fs.lstat(extractedAuth);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error("Archive is not a fleet cell backup or was tampered with.", {
cause: error,
});
}
throw error;
}
if (!authStat.isDirectory() || authStat.isSymbolicLink()) {
throw new Error("Archive is not a fleet cell backup or was tampered with.");
}
const containerUser = await resolveContainerUser({
runtime: params.record.runtime,
containers: params.containers,
hostIdentity: params.hostIdentity,
user: inspection.user,
});
const imageOwner = resolveRestoreOwner(params.hostIdentity, containerUser);
// Build and validate the replacement profile before any destructive step so a
// drifted-but-managed container (bad provenance label, invalid inspected limits)
// fails preflight instead of after the old container and state are gone.
const token = params.generateToken();
const attemptId = params.generateAttemptId();
replacementAttemptId = attemptId;
const profile: CellContainerProfile = {
...buildProfileBaseFromInspection({
record: params.record,
stateDir: params.stateDir,
inspection,
containerUser,
selinuxRelabel: params.selinuxRelabel,
token,
context: "restore",
}),
image: inspection.imageId,
attemptId,
};
validateCellContainerProfile(profile);
const authSecretDir = cellAuthSecretDir(params.stateDir, params.record.tenantId);
const dataTarget = await resolvePurgeTarget(
path.join(params.stateDir, "fleet", "cells"),
params.record.dataDir,
params.record.tenantId,
);
const authTarget = await resolvePurgeTarget(
path.join(params.stateDir, "fleet", "auth-profile-secrets"),
authSecretDir,
params.record.tenantId,
);
const replacedRoot = path.join(tempDir, "replaced");
await fs.mkdir(replacedRoot, { mode: 0o700 });
assertManagedNetwork(
params.record,
await params.containers.inspectNetwork(
params.record.runtime,
cellNetworkName(params.record.tenantId),
),
);
// Restore decided to displace the generation inspected above, so every
// re-validation below re-inspects that identity rather than the cell name.
// Re-inspecting the name would let a container that claimed it in the
// meantime pass the ownership guard and be stopped or removed instead.
if (wasRunning) {
params.checkpoint();
const running = assertManagedInspection(
params.record,
await params.containers.inspect(params.record.runtime, inspection.containerId),
);
await params.containers.stop(params.record.runtime, running.containerId);
stoppedForRestore = true;
}
params.checkpoint();
const removable = assertManagedInspection(
params.record,
await params.containers.inspect(params.record.runtime, inspection.containerId),
);
await params.containers.remove(params.record.runtime, removable.containerId, false);
containerRemoved = true;
params.checkpoint();
previousDisplaced = true;
if (dataTarget) {
await fs.rename(dataTarget, path.join(replacedRoot, "data"));
}
if (authTarget) {
await fs.rename(authTarget, path.join(replacedRoot, "auth"));
}
await fs.rename(extractedData, params.record.dataDir);
await fs.rename(extractedAuth, authSecretDir);
stateSwapped = true;
await prepareCellDirectories(params.record, authSecretDir, imageOwner);
if (imageOwner) {
await Promise.all([
chownTree(params.record.dataDir, imageOwner),
chownTree(authSecretDir, imageOwner),
]);
}
await prepareCellConfig(params.record, imageOwner);
params.checkpoint();
await params.containers.run(profile, wasRunning);
if (wasRunning) {
await verifyReplacementHealthy({
containers: params.containers,
record: params.record,
attemptId,
fetchImpl: params.fetchImpl,
now: params.now,
sleep: params.sleep,
checkpoint: params.checkpoint,
timeoutMs: RESTORE_VERIFY_TIMEOUT_MS,
pollMs: RESTORE_VERIFY_POLL_MS,
context: "restore",
});
}
return {
tenant: params.record.tenantId,
archivePath,
token,
tokenNote: "Shown once. The previous Gateway token was rotated by this restore.",
started: wasRunning,
url: `http://127.0.0.1:${params.record.hostPort}`,
};
} catch (error) {
if (containerRemoved) {
preserveTemp = true;
// The rotated token was never delivered, so a live replacement must not
// keep serving. Detect it by attempt label (containers.run can fail after
// partially starting), and never claim it was stopped when it was not.
let replacementNote = "";
try {
const current = await params.containers.inspect(
params.record.runtime,
params.record.containerName,
);
if (
current.kind === "ok" &&
current.labels[FLEET_ATTEMPT_LABEL] === replacementAttemptId &&
current.running
) {
await params.containers.stop(params.record.runtime, current.containerId);
replacementNote =
" The interrupted replacement container was stopped; retry fleet restore to rotate a fresh Gateway token.";
} else if (current.kind === "unavailable") {
// An unavailable runtime cannot prove the replacement is down.
replacementNote =
" The replacement container state could not be verified; stop it manually before retrying fleet restore.";
}
} catch {
replacementNote =
" The interrupted replacement container could not be stopped; stop it manually before retrying fleet restore.";
}
// Report where each tree actually is for the failure phase: before the
// swap the previous data never moved; after it, the restored trees are
// already live and only the displaced originals sit under replaced/.
const recoveryNote = stateSwapped
? ` Restored data is already in place under the cell directories; displaced previous data is preserved at ${tempDir}/replaced.`
: previousDisplaced
? ` Previous data is preserved at ${tempDir}/replaced and the extracted archive remains at ${tempDir}.`
: ` Previous cell data remains in place; the extracted archive remains at ${tempDir}.`;
throw new Error(
`Fleet restore for ${params.record.tenantId} was interrupted after the cell container was removed: ${errorMessage(error)}.${replacementNote}${recoveryNote}`,
{ cause: error },
);
}
if (stoppedForRestore) {
// A --force restore stopped a running cell but failed before removal.
// Restart the same managed generation so an aborted restore does not
// strand a healthy tenant stopped; the original error stays primary.
try {
// Same generation by identity, so no attempt-label comparison is needed:
// the cell name may already point at something this must not start.
const current = assertManagedInspection(
params.record,
await params.containers.inspect(params.record.runtime, inspection.containerId),
);
if (!current.running) {
await params.containers.start(params.record.runtime, current.containerId);
}
} catch {
// Best-effort recovery; the container remains stopped but intact.
}
}
throw error;
} finally {
if (!preserveTemp) {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */