openclaw / src /fleet /backup.runtime.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
76289e7 verified
Raw History Blame Contribute Delete
29.5 kB
import fs from "node:fs/promises";
import path from "node:path";
import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks";
import * as tar from "tar";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { backupFleetCell, restoreFleetCell } from "./backup.runtime.js";
import { cellAuthSecretDir, cellOwnerId } from "./cell-profile.js";
import type { FleetContainerInspectResult, FleetContainerRuntime } from "./containers.runtime.js";
import type { FleetCellRecord } from "./registry.js";
const ATTEMPT = "11111111111111111111111111111111";
const NEXT_ATTEMPT = "22222222222222222222222222222222";
let root: string;
let record: FleetCellRecord;
const tempRoot = createSuiteTempRootTracker({ prefix: "openclaw-fleet-backup-test-" });
function inspection(running = false): Extract<FleetContainerInspectResult, { kind: "ok" }> {
return {
kind: "ok",
containerId: "container-id",
state: running ? "running" : "exited",
running,
labels: {
"openclaw.fleet.tenant": "acme",
"openclaw.fleet.owner": cellOwnerId(record.dataDir),
"openclaw.fleet.attempt": ATTEMPT,
"openclaw.fleet.env-keys": "",
"openclaw.fleet.disk-limit": "10g",
},
environment: { OPENCLAW_GATEWAY_TOKEN: "old-token" },
imageId: "sha256:image",
memory: "2147483648",
cpus: "2",
pidsLimit: 512,
storageOpt: { size: "10g" },
capDrop: ["ALL"],
effectiveCaps: undefined,
securityOpt: ["no-new-privileges"],
init: true,
restartPolicy: "unless-stopped",
portBindings: [{ containerPort: "18789/tcp", hostIp: "127.0.0.1", hostPort: "19100" }],
user: "1000:1000",
};
}
function containerMock(current: FleetContainerInspectResult = inspection()) {
return {
assertLocal: vi.fn(async () => undefined),
inspect: vi.fn<FleetContainerRuntime["inspect"]>(async () => current),
inspectNetwork: vi.fn(async () => ({
kind: "ok" as const,
labels: {
"openclaw.fleet.tenant": "acme",
"openclaw.fleet.owner": cellOwnerId(record.dataDir),
},
attachedContainers: [{ id: "cell", name: record.containerName }],
internal: false,
})),
isDockerRootless: vi.fn(async () => false),
run: vi.fn<FleetContainerRuntime["run"]>(async () => undefined),
pull: vi.fn(async () => undefined),
createNetwork: vi.fn(async () => undefined),
removeNetwork: vi.fn(async () => undefined),
logs: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
restart: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
} satisfies FleetContainerRuntime;
}
async function createArchive(
params: { tenant?: string; mutate?: (dir: string) => Promise<void> } = {},
): Promise<string> {
const source = path.join(root, `archive-source-${crypto.randomUUID()}`);
await fs.mkdir(path.join(source, "data"), { recursive: true });
await fs.mkdir(path.join(source, "auth"), { recursive: true });
await fs.writeFile(
path.join(source, "manifest.json"),
JSON.stringify({
schemaVersion: 1,
kind: "openclaw-fleet-cell-backup",
tenant: params.tenant ?? "acme",
createdAt: new Date(0).toISOString(),
hostPort: 19100,
image: "image",
runtime: "docker",
}),
);
await fs.writeFile(path.join(source, "data", "restored.txt"), "new-data");
await fs.writeFile(path.join(source, "auth", "secret.txt"), "new-auth");
await params.mutate?.(source);
const archive = path.join(root, `${path.basename(source)}.tgz`);
await tar.c({ gzip: true, file: archive, cwd: source }, ["manifest.json", "data", "auth"]);
return archive;
}
beforeEach(async () => {
root = await tempRoot.setup();
record = {
tenantId: "acme",
createdAtMs: 0,
image: "image",
runtime: "docker",
hostPort: 19100,
containerName: "openclaw-cell-acme",
dataDir: path.join(root, "fleet", "cells", "acme"),
};
await fs.mkdir(record.dataDir, { recursive: true, mode: 0o700 });
await fs.mkdir(cellAuthSecretDir(root, "acme"), { recursive: true, mode: 0o700 });
await fs.writeFile(path.join(record.dataDir, "state.txt"), "state");
await fs.writeFile(path.join(cellAuthSecretDir(root, "acme"), "secret.txt"), "secret");
});
afterEach(async () => {
__setFsSafeTestHooksForTest(undefined);
vi.restoreAllMocks();
await tempRoot.cleanup();
});
describe("fleet backup runtime", () => {
function backupParams(out: string) {
return {
record,
stateDir: root,
containers: containerMock(),
now: () => 0,
checkpoint: () => {},
out,
};
}
function interruptCopy(archivePath: string, mutate: (targetPath: string) => Promise<void>) {
const error = Object.assign(new Error("archive copy interrupted"), { code: "EIO" });
vi.spyOn(fs, "link").mockRejectedValue(
Object.assign(new Error("unsupported"), { code: "ENOTSUP" }),
);
const copyFile = fs.copyFile.bind(fs);
const legacyCopy = vi.spyOn(fs, "copyFile").mockImplementation(async (source, target, mode) => {
if (path.resolve(String(target)) !== archivePath) {
return await copyFile(source, target, mode);
}
await fs.writeFile(target, "");
await mutate(String(target));
throw error;
});
__setFsSafeTestHooksForTest({
afterPublishTargetCreated: async (method, targetPath) => {
if (method === "exclusive-copy" && targetPath === archivePath) {
await mutate(targetPath);
throw error;
}
},
});
return legacyCopy;
}
it("writes a private archive with manifest, data, and auth while skipping symlinks", async () => {
const outside = path.join(root, "outside-secret");
await fs.writeFile(outside, "must-not-archive");
await fs.symlink(outside, path.join(record.dataDir, "outside-link"));
const containers = containerMock();
const publicationMethods: string[] = [];
__setFsSafeTestHooksForTest({
afterPublishTargetCreated: (method) => {
publicationMethods.push(method);
},
});
const result = await backupFleetCell({
record,
stateDir: root,
containers,
now: () => 0,
checkpoint: () => {},
out: path.join(root, "backup.tgz"),
});
expect((await fs.stat(result.archivePath)).mode & 0o777).toBe(0o600);
expect(publicationMethods).toEqual(["hardlink"]);
expect(result.skippedSymlinks).toBe(1);
const entries: string[] = [];
const contents: string[] = [];
await tar.t({
file: result.archivePath,
onentry: (entry) => {
entries.push(entry.path);
entry.on("data", (chunk) => contents.push(String(chunk)));
},
});
expect(entries).toEqual(
expect.arrayContaining(["manifest.json", "data/state.txt", "auth/secret.txt"]),
);
expect(entries).not.toContain("data/outside-link");
expect(contents.join("")).not.toContain("must-not-archive");
const leftovers = (await fs.readdir(path.dirname(result.archivePath))).filter((name) =>
name.endsWith(".tmp"),
);
expect(leftovers).toEqual([]);
});
it("publishes a complete archive through the copy fallback", async () => {
const archivePath = path.join(root, "copy.tgz");
vi.spyOn(fs, "link").mockRejectedValue(
Object.assign(new Error("unsupported"), { code: "ENOTSUP" }),
);
const methods: string[] = [];
__setFsSafeTestHooksForTest({
afterPublishTargetCreated: (method) => {
methods.push(method);
},
});
expect((await backupFleetCell(backupParams(archivePath))).archivePath).toBe(archivePath);
expect(methods).toEqual(["exclusive-copy"]);
await expect(tar.t({ file: archivePath })).resolves.toBeUndefined();
});
it("removes an interrupted owned copy and allows a backup retry", async () => {
const archivePath = path.join(root, "interrupted.tgz");
const legacyCopy = interruptCopy(archivePath, (targetPath) =>
fs.writeFile(targetPath, "partial archive"),
);
await expect(backupFleetCell(backupParams(archivePath))).rejects.toThrow(
/archive copy interrupted/iu,
);
await expect(fs.lstat(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
__setFsSafeTestHooksForTest(undefined);
legacyCopy.mockRestore();
await expect(backupFleetCell(backupParams(archivePath))).resolves.toMatchObject({
archivePath,
});
});
it("reports the original failure when an interrupted archive cannot be removed", async () => {
const archivePath = path.join(root, "cleanup-unknown.tgz");
interruptCopy(archivePath, (targetPath) => fs.writeFile(targetPath, "partial archive"));
const remove = fs.rm.bind(fs);
vi.spyOn(fs, "rm").mockImplementation(async (target, options) => {
if (path.resolve(String(target)) === archivePath) {
throw Object.assign(new Error("archive cleanup busy"), { code: "EBUSY" });
}
return await remove(target, options);
});
const error = await backupFleetCell(backupParams(archivePath)).catch(
(caught: unknown) => caught,
);
expect(error).toMatchObject({
message: expect.stringMatching(
/archive copy interrupted.*partial archive may remain.*inspect.*retry/iu,
),
cause: expect.any(Error),
});
expect((error as Error).message).toContain(archivePath);
await expect(fs.readFile(archivePath, "utf8")).resolves.toBe("partial archive");
});
it("preserves a foreign archive that replaces the interrupted publication", async () => {
const archivePath = path.join(root, "raced.tgz");
interruptCopy(archivePath, async (targetPath) => {
await fs.rename(targetPath, `${targetPath}.displaced`);
await fs.writeFile(targetPath, "foreign archive");
});
await expect(backupFleetCell(backupParams(archivePath))).rejects.toThrow(
/archive copy interrupted/iu,
);
await expect(fs.readFile(archivePath, "utf8")).resolves.toBe("foreign archive");
});
it("fails and removes its archive when publication directory synchronization fails", async () => {
const archivePath = path.join(root, "sync-failure.tgz");
__setFsSafeTestHooksForTest({
beforePublishDirectorySync: async (_method, targetPath) => {
if (targetPath === archivePath) {
throw Object.assign(new Error("archive directory sync failed"), { code: "EIO" });
}
},
});
await expect(backupFleetCell(backupParams(archivePath))).rejects.toThrow(
/directory sync failed/iu,
);
await expect(fs.lstat(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("rejects and removes a copy that fails publication content verification", async () => {
const archivePath = path.join(root, "integrity-failure.tgz");
vi.spyOn(fs, "link").mockRejectedValue(
Object.assign(new Error("unsupported"), { code: "ENOTSUP" }),
);
__setFsSafeTestHooksForTest({
afterPublishTargetCreated: async (method, targetPath) => {
if (method === "exclusive-copy" && targetPath === archivePath) {
await fs.writeFile(targetPath, Buffer.alloc(64 * 1024, 1));
}
},
});
await expect(backupFleetCell(backupParams(archivePath))).rejects.toThrow(/content fencing/iu);
await expect(fs.lstat(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("refuses unsafe or unavailable backup inputs", async () => {
await expect(
backupFleetCell({
record,
stateDir: root,
containers: containerMock(inspection(true)),
now: () => 0,
checkpoint: () => {},
}),
).rejects.toThrow(/stop it first/iu);
await fs.rm(cellAuthSecretDir(root, "acme"), { recursive: true });
await expect(
backupFleetCell({
record,
stateDir: root,
containers: containerMock(),
now: () => 0,
checkpoint: () => {},
}),
).rejects.toThrow(/no auth-secret directory/iu);
await fs.rm(record.dataDir, { recursive: true });
await expect(
backupFleetCell({
record,
stateDir: root,
containers: containerMock(),
now: () => 0,
checkpoint: () => {},
}),
).rejects.toThrow(/no cell data/iu);
});
it("refuses byte caps, existing outputs, and outputs inside cell data", async () => {
const containers = containerMock();
await expect(
backupFleetCell({
record,
stateDir: root,
containers,
now: () => 0,
checkpoint: () => {},
maxBytes: 1,
out: path.join(root, "capped.tgz"),
}),
).rejects.toThrow(/--max-bytes/iu);
const existing = path.join(root, "existing.tgz");
await fs.writeFile(existing, "exists");
await expect(
backupFleetCell({
record,
stateDir: root,
containers,
now: () => 0,
checkpoint: () => {},
out: existing,
}),
).rejects.toThrow(/overwrite/iu);
await expect(fs.readFile(existing, "utf8")).resolves.toBe("exists");
await expect(
backupFleetCell({
record,
stateDir: root,
containers,
now: () => 0,
checkpoint: () => {},
out: path.join(record.dataDir, "bad.tgz"),
}),
).rejects.toThrow(/inside/iu);
});
it("refuses file names its own restore rules would reject", async () => {
await fs.writeFile(path.join(record.dataDir, "weird\\..\\name"), "content");
await expect(
backupFleetCell({
record,
stateDir: root,
containers: containerMock(),
now: () => 0,
checkpoint: () => {},
out: path.join(root, "unrestorable.tgz"),
}),
).rejects.toThrow(/restore path rules would reject/iu);
await expect(fs.lstat(path.join(root, "unrestorable.tgz"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("rejects trees over the entry limit", async () => {
await expect(
backupFleetCell({
record,
stateDir: root,
containers: containerMock(),
now: () => 0,
checkpoint: () => {},
maxEntries: 2,
out: path.join(root, "entry-capped.tgz"),
}),
).rejects.toThrow(/entry limit/iu);
await expect(fs.lstat(path.join(root, "entry-capped.tgz"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("aborts and discards the archive when the operation lease is lost mid-stream", async () => {
let clock = 0;
const archivePath = path.join(root, "lease-lost.tgz");
await expect(
backupFleetCell({
record,
stateDir: root,
containers: containerMock(),
// Each filter probe advances well past the lease-probe interval.
now: () => (clock += 60_000),
checkpoint: () => {
throw new Error("Fleet operation lease was lost for acme.");
},
out: archivePath,
}),
).rejects.toThrow(/lost its operation lease/iu);
await expect(fs.lstat(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
});
});
describe("fleet restore runtime", () => {
function restoreParams(containers: FleetContainerRuntime, from: string) {
return {
record,
stateDir: root,
containers,
fetchImpl: vi.fn<typeof fetch>(async () => new Response(null, { status: 200 })),
now: () => 0,
sleep: async () => {},
checkpoint: () => {},
generateToken: () => "new-token",
generateAttemptId: () => NEXT_ATTEMPT,
hostIdentity: undefined,
selinuxRelabel: false,
from,
};
}
it("rejects tenant mismatch and running cells before mutation", async () => {
const mismatch = await createArchive({ tenant: "other" });
const stopped = containerMock();
await expect(restoreFleetCell(restoreParams(stopped, mismatch))).rejects.toThrow(
/belongs to tenant other/iu,
);
expect(stopped.remove).not.toHaveBeenCalled();
const archive = await createArchive();
const running = containerMock(inspection(true));
await expect(restoreFleetCell(restoreParams(running, archive))).rejects.toThrow(
/pass --force/iu,
);
});
it("gives a usable recovery sequence when the registered container is missing", async () => {
const archive = await createArchive();
const containers = containerMock({ kind: "missing", state: "missing" });
await expect(restoreFleetCell(restoreParams(containers, archive))).rejects.toThrow(
/fleet rm acme --force.*fleet create acme --no-start --image <image>.*retry fleet restore/iu,
);
expect(containers.remove).not.toHaveBeenCalled();
});
it("rejects archives inside cell state and foreign cell networks before mutation", async () => {
const archive = await createArchive();
const inside = path.join(record.dataDir, "inside.tgz");
await fs.copyFile(archive, inside);
const containers = containerMock();
await expect(restoreFleetCell(restoreParams(containers, inside))).rejects.toThrow(
/must not be stored inside/iu,
);
expect(containers.remove).not.toHaveBeenCalled();
containers.inspectNetwork.mockResolvedValue({
kind: "ok",
labels: { "openclaw.fleet.tenant": "acme", "openclaw.fleet.owner": "foreign" },
attachedContainers: [],
internal: false,
});
await expect(restoreFleetCell(restoreParams(containers, archive))).rejects.toThrow(
/ownership labels/iu,
);
expect(containers.remove).not.toHaveBeenCalled();
});
it.each([
[
"symlink",
async (dir: string) => await fs.symlink("../manifest.json", path.join(dir, "data", "link")),
],
[
"unexpected root",
async (dir: string) => await fs.writeFile(path.join(dir, "unexpected"), "bad"),
],
] as const)("rejects an archive containing %s", async (_label, mutate) => {
const source = path.join(root, `malicious-${crypto.randomUUID()}`);
await fs.mkdir(path.join(source, "data"), { recursive: true });
await fs.writeFile(
path.join(source, "manifest.json"),
JSON.stringify({ schemaVersion: 1, kind: "openclaw-fleet-cell-backup", tenant: "acme" }),
);
await mutate(source);
const archive = path.join(root, `${path.basename(source)}.tgz`);
await tar.c({ gzip: true, file: archive, cwd: source }, ["."]);
await expect(restoreFleetCell(restoreParams(containerMock(), archive))).rejects.toThrow(
/tampered/iu,
);
});
it("rejects archives over the entry limit before any destructive step", async () => {
const archive = await createArchive();
const containers = containerMock();
await expect(
restoreFleetCell({ ...restoreParams(containers, archive), maxEntries: 2 }),
).rejects.toThrow(/entry limit/iu);
expect(containers.remove).not.toHaveBeenCalled();
});
it("rejects an implicitly deep entry before creating output paths", async () => {
const source = path.join(root, `deep-${crypto.randomUUID()}`);
const deepRelativePath = path.posix.join(
"data",
...Array.from({ length: 6 }, (_, index) => `segment-${index}`),
"state.txt",
);
await fs.mkdir(path.join(source, path.dirname(deepRelativePath)), { recursive: true });
await fs.mkdir(path.join(source, "auth"));
await fs.writeFile(
path.join(source, "manifest.json"),
JSON.stringify({ schemaVersion: 1, kind: "openclaw-fleet-cell-backup", tenant: "acme" }),
);
await fs.writeFile(path.join(source, deepRelativePath), "state");
const archive = path.join(root, `${path.basename(source)}.tgz`);
await tar.c({ gzip: true, file: archive, cwd: source }, [
"manifest.json",
deepRelativePath,
"auth",
]);
const containers = containerMock();
await expect(
restoreFleetCell({ ...restoreParams(containers, archive), maxEntries: 5 }),
).rejects.toThrow(/entry limit/iu);
expect(containers.remove).not.toHaveBeenCalled();
await expect(fs.readdir(path.join(root, "fleet", "restore-tmp"))).resolves.toEqual([]);
});
it("rejects an archive without the auth tree before any destructive step", async () => {
const source = path.join(root, `auth-less-${crypto.randomUUID()}`);
await fs.mkdir(path.join(source, "data"), { recursive: true });
await fs.writeFile(
path.join(source, "manifest.json"),
JSON.stringify({ schemaVersion: 1, kind: "openclaw-fleet-cell-backup", tenant: "acme" }),
);
await fs.writeFile(path.join(source, "data", "restored.txt"), "new-data");
const archive = path.join(root, `${path.basename(source)}.tgz`);
await tar.c({ gzip: true, file: archive, cwd: source }, ["manifest.json", "data"]);
const containers = containerMock();
await expect(restoreFleetCell(restoreParams(containers, archive))).rejects.toThrow(
/tampered/iu,
);
expect(containers.stop).not.toHaveBeenCalled();
expect(containers.remove).not.toHaveBeenCalled();
await expect(
fs.readFile(path.join(cellAuthSecretDir(root, "acme"), "secret.txt"), "utf8"),
).resolves.toBe("secret");
});
it("fails preflight on a drifted container before any destructive step", async () => {
const archive = await createArchive();
const drifted = inspection();
// Losing the env-provenance label makes the replacement profile unbuildable;
// restore must detect that before stopping or removing anything.
delete drifted.labels["openclaw.fleet.env-keys"];
const containers = containerMock(drifted);
await expect(restoreFleetCell(restoreParams(containers, archive))).rejects.toThrow(
/Cannot restore cell/iu,
);
expect(containers.stop).not.toHaveBeenCalled();
expect(containers.remove).not.toHaveBeenCalled();
await expect(fs.readFile(path.join(record.dataDir, "state.txt"), "utf8")).resolves.toBe(
"state",
);
});
it.each([
{
name: "generated previous default",
cache: "/home/node/.cache",
keys: [],
expectedCache: "/home/node/.openclaw/cache",
},
{
name: "explicit matching default",
cache: "/home/node/.openclaw/cache",
keys: ["XDG_CACHE_HOME"],
expectedCache: "/home/node/.openclaw/cache",
},
{
name: "explicit previous default",
cache: "/home/node/.cache",
keys: ["XDG_CACHE_HOME"],
expectedCache: "/home/node/.cache",
},
])("restores state and token with $name", async ({ cache, keys, expectedCache }) => {
const archive = await createArchive();
const current = inspection();
current.labels["openclaw.fleet.env-keys"] = keys.join(",");
current.environment.XDG_CACHE_HOME = cache;
const containers = containerMock(current);
const result = await restoreFleetCell(restoreParams(containers, archive));
await expect(fs.readFile(path.join(record.dataDir, "restored.txt"), "utf8")).resolves.toBe(
"new-data",
);
await expect(fs.lstat(path.join(record.dataDir, "state.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
const config = JSON.parse(
await fs.readFile(path.join(record.dataDir, "openclaw.json"), "utf8"),
) as { gateway?: { controlUi?: { allowedOrigins?: string[] } } };
expect(config.gateway?.controlUi?.allowedOrigins).toContain("http://127.0.0.1:19100");
expect(containers.run.mock.calls[0]?.[0].environment.OPENCLAW_GATEWAY_TOKEN).toBe("new-token");
expect(containers.run.mock.calls[0]?.[0].environment.XDG_CACHE_HOME).toBe(expectedCache);
expect(containers.run.mock.calls[0]?.[0].userEnvironmentKeys).toEqual(keys);
// The disk limit must survive restore via the fleet label even on Podman,
// whose inspect schema has no HostConfig.StorageOpt.
expect(containers.run.mock.calls[0]?.[0].diskSize).toBe("10g");
expect(containers.run.mock.calls[0]?.[0].environment.OPENCLAW_GATEWAY_TOKEN).not.toBe(
"old-token",
);
expect(containers.run).toHaveBeenCalledWith(expect.any(Object), false);
expect(result.started).toBe(false);
await expect(fs.readdir(path.join(root, "fleet", "restore-tmp"))).resolves.toEqual([]);
});
it.each([true, false])(
"removes the inspected generation, not a replacement that took the name (wasRunning: %s)",
async (wasRunning) => {
const archive = await createArchive();
const running = inspection(wasRunning);
const containers = containerMock(running);
// Restore's first lookup finds the real cell. Immediately afterwards a
// replacement claims the cell name; it carries valid fleet ownership
// labels and would pass the guard, so only pinning the inspected identity
// keeps stop and remove on the generation restore decided to displace.
const replacement = {
...inspection(true),
containerId: "replacement-id",
labels: { ...inspection(true).labels, "openclaw.fleet.attempt": NEXT_ATTEMPT },
};
containers.inspect.mockImplementationOnce(async () => {
containers.inspect.mockImplementation(async (_runtime, reference) =>
reference === "container-id" ? running : replacement,
);
return running;
});
containers.stop.mockImplementation(async () => {
running.running = false;
running.state = "exited";
});
await restoreFleetCell({ ...restoreParams(containers, archive), force: true });
if (wasRunning) {
expect(containers.stop).toHaveBeenCalledWith("docker", "container-id");
expect(containers.stop).not.toHaveBeenCalledWith("docker", "replacement-id");
}
expect(containers.remove).toHaveBeenCalledWith("docker", "container-id", false);
expect(containers.remove).not.toHaveBeenCalledWith("docker", "replacement-id", false);
},
);
it("restarts a force-stopped cell when restore fails before removal", async () => {
const archive = await createArchive();
const running = inspection(true);
const containers = containerMock(running);
containers.stop.mockImplementation(async () => {
running.running = false;
running.state = "exited";
});
containers.remove.mockRejectedValue(new Error("transient removal failure"));
await expect(
restoreFleetCell({ ...restoreParams(containers, archive), force: true }),
).rejects.toThrow(/transient removal failure/iu);
expect(containers.start).toHaveBeenCalledWith("docker", "container-id");
await expect(fs.readFile(path.join(record.dataDir, "state.txt"), "utf8")).resolves.toBe(
"state",
);
});
it("stops an unhealthy started replacement so its undelivered token cannot serve", async () => {
const archive = await createArchive();
const running = inspection(true);
const containers = containerMock(running);
containers.stop.mockImplementation(async () => {
running.running = false;
running.state = "exited";
});
containers.run.mockImplementation(async () => {
running.running = true;
running.state = "running";
running.labels["openclaw.fleet.attempt"] = NEXT_ATTEMPT;
});
let clock = 0;
let message = "";
try {
await restoreFleetCell({
...restoreParams(containers, archive),
force: true,
fetchImpl: vi.fn<typeof fetch>(async () => new Response(null, { status: 500 })),
now: () => (clock += 61_000),
});
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toMatch(/replacement container was stopped/iu);
expect(containers.stop).toHaveBeenCalledTimes(2);
});
it.each([
["non-root invoker", { uid: 501, gid: 20 }, undefined, undefined],
["root with image-default user", { uid: 0, gid: 0 }, undefined, { uid: 1000, gid: 1000 }],
[
"root with explicit non-root mapping",
{ uid: 0, gid: 0 },
"1001:1002",
{ uid: 1001, gid: 1002 },
],
["root with rootless uid-0 mapping", { uid: 0, gid: 0 }, "0:0", undefined],
] as const)("repairs restored ownership for %s", async (_label, hostIdentity, user, expected) => {
const archive = await createArchive();
const current = inspection();
current.user = user;
const containers = containerMock(current);
const chown = vi.spyOn(fs, "chown").mockResolvedValue();
await restoreFleetCell({
...restoreParams(containers, archive),
hostIdentity,
});
if (!expected) {
expect(chown).not.toHaveBeenCalled();
return;
}
expect(chown).toHaveBeenCalled();
for (const [, uid, gid] of chown.mock.calls) {
expect({ uid, gid }).toEqual(expected);
}
});
it("preserves replaced and extracted trees when replacement run fails", async () => {
const archive = await createArchive();
const containers = containerMock();
containers.run.mockRejectedValue(new Error("run failed"));
let message = "";
try {
await restoreFleetCell(restoreParams(containers, archive));
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toMatch(/displaced previous data is preserved at .*\/replaced/iu);
const preserved = message.match(/preserved at (.+)\/replaced\./u)?.[1];
expect(preserved).toBeTruthy();
await expect(
fs.readFile(path.join(preserved ?? "", "replaced", "data", "state.txt"), "utf8"),
).resolves.toBe("state");
});
});