mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 13:51:35 +00:00
1084 lines
42 KiB
TypeScript
1084 lines
42 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
|
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
|
import { cellAuthSecretDir, cellOwnerId } from "./cell-profile.js";
|
|
import type { FleetContainerInspectResult, FleetContainerRuntime } from "./containers.runtime.js";
|
|
import { deleteFleetCell, getFleetCell, listFleetCells } from "./registry.js";
|
|
import {
|
|
createFleetService as createFleetServiceRuntime,
|
|
type FleetServiceOptions,
|
|
} from "./service.runtime.js";
|
|
|
|
let root: string;
|
|
const TEST_ATTEMPT_ID = "22222222222222222222222222222222";
|
|
const NEXT_ATTEMPT_ID = "44444444444444444444444444444444";
|
|
|
|
function createFleetService(options: FleetServiceOptions = {}) {
|
|
return createFleetServiceRuntime({ probePort: async () => true, ...options });
|
|
}
|
|
|
|
function fleetLabels(tenant = "acme", attemptId = TEST_ATTEMPT_ID): Record<string, string> {
|
|
return {
|
|
"openclaw.fleet.tenant": tenant,
|
|
"openclaw.fleet.owner": cellOwnerId(path.join(root, "fleet", "cells", tenant)),
|
|
"openclaw.fleet.attempt": attemptId,
|
|
"openclaw.fleet.env-keys": "FEATURE",
|
|
};
|
|
}
|
|
|
|
function runningInspection(
|
|
overrides: Partial<Extract<FleetContainerInspectResult, { kind: "ok" }>> = {},
|
|
): Extract<FleetContainerInspectResult, { kind: "ok" }> {
|
|
return {
|
|
kind: "ok",
|
|
state: "running",
|
|
running: true,
|
|
labels: fleetLabels(),
|
|
environment: {
|
|
HOME: "/home/node",
|
|
OPENCLAW_GATEWAY_TOKEN: "old-token",
|
|
FEATURE: "enabled",
|
|
NODE_VERSION: "old-image-default",
|
|
},
|
|
imageId: "sha256:old-image-id",
|
|
memory: "2147483648",
|
|
cpus: "2",
|
|
pidsLimit: 512,
|
|
storageOpt: {},
|
|
capDrop: ["ALL"],
|
|
effectiveCaps: undefined,
|
|
securityOpt: ["no-new-privileges"],
|
|
init: true,
|
|
restartPolicy: "unless-stopped",
|
|
portBindings: [{ containerPort: "18789/tcp", hostIp: "127.0.0.1", hostPort: "19100" }],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createContainerMock(
|
|
initialInspection: FleetContainerInspectResult = {
|
|
kind: "missing",
|
|
state: "missing",
|
|
},
|
|
) {
|
|
const assertLocal = vi.fn<FleetContainerRuntime["assertLocal"]>(async () => undefined);
|
|
const inspections = new Map<string, FleetContainerInspectResult>();
|
|
const removedContainers = new Set<string>();
|
|
const inspect = vi.fn<FleetContainerRuntime["inspect"]>(async (_runtime, name) =>
|
|
removedContainers.has(name)
|
|
? { kind: "missing", state: "missing" }
|
|
: (inspections.get(name) ?? initialInspection),
|
|
);
|
|
const networks = new Map<
|
|
string,
|
|
Extract<Awaited<ReturnType<FleetContainerRuntime["inspectNetwork"]>>, { kind: "ok" }>
|
|
>();
|
|
const inspectNetwork = vi.fn<FleetContainerRuntime["inspectNetwork"]>(
|
|
async (_runtime, name) => networks.get(name) ?? { kind: "missing" },
|
|
);
|
|
const isDockerRootless = vi.fn<FleetContainerRuntime["isDockerRootless"]>(async () => false);
|
|
const run = vi.fn<FleetContainerRuntime["run"]>(async (profile, start) => {
|
|
removedContainers.delete(profile.containerName);
|
|
inspections.set(
|
|
profile.containerName,
|
|
runningInspection({
|
|
state: start ? "running" : "created",
|
|
running: start,
|
|
labels: fleetLabels(profile.tenantId, profile.attemptId),
|
|
environment: { ...profile.environment },
|
|
imageId: `sha256:${profile.attemptId}`,
|
|
memory: profile.memory,
|
|
cpus: profile.cpus,
|
|
pidsLimit: profile.pidsLimit,
|
|
}),
|
|
);
|
|
});
|
|
const pull = vi.fn<FleetContainerRuntime["pull"]>(async () => undefined);
|
|
const createNetwork = vi.fn<FleetContainerRuntime["createNetwork"]>(
|
|
async (_runtime, name, labels, options) => {
|
|
networks.set(name, {
|
|
kind: "ok",
|
|
labels: { ...labels },
|
|
attachedContainers: [],
|
|
internal: options.internal,
|
|
});
|
|
},
|
|
);
|
|
const removeNetwork = vi.fn<FleetContainerRuntime["removeNetwork"]>(async (_runtime, name) => {
|
|
networks.delete(name);
|
|
});
|
|
const start = vi.fn<FleetContainerRuntime["start"]>(async (_runtime, name) => {
|
|
const current = await inspect("docker", name);
|
|
if (current.kind === "ok") {
|
|
inspections.set(name, { ...current, state: "running", running: true });
|
|
}
|
|
});
|
|
const stop = vi.fn<FleetContainerRuntime["stop"]>(async () => undefined);
|
|
const restart = vi.fn<FleetContainerRuntime["restart"]>(async () => undefined);
|
|
const logs = vi.fn<FleetContainerRuntime["logs"]>(async () => undefined);
|
|
const remove = vi.fn<FleetContainerRuntime["remove"]>(async (_runtime, name) => {
|
|
inspections.delete(name);
|
|
removedContainers.add(name);
|
|
inspect.mockResolvedValue({ kind: "missing", state: "missing" });
|
|
});
|
|
return {
|
|
runtime: {
|
|
assertLocal,
|
|
inspect,
|
|
inspectNetwork,
|
|
isDockerRootless,
|
|
run,
|
|
pull,
|
|
createNetwork,
|
|
removeNetwork,
|
|
start,
|
|
stop,
|
|
restart,
|
|
logs,
|
|
remove,
|
|
},
|
|
assertLocal,
|
|
inspect,
|
|
inspectNetwork,
|
|
isDockerRootless,
|
|
run,
|
|
pull,
|
|
createNetwork,
|
|
removeNetwork,
|
|
start,
|
|
stop,
|
|
restart,
|
|
logs,
|
|
remove,
|
|
};
|
|
}
|
|
|
|
describe("fleet service", () => {
|
|
let env: NodeJS.ProcessEnv;
|
|
|
|
const tempRoot = createSuiteTempRootTracker({ prefix: "openclaw-fleet-service-" });
|
|
|
|
beforeEach(async () => {
|
|
root = await tempRoot.setup();
|
|
env = { ...process.env, OPENCLAW_STATE_DIR: root };
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn<typeof fetch>(async () => new Response(null, { status: 200 })),
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
closeOpenClawStateDatabaseForTest();
|
|
vi.unstubAllGlobals();
|
|
await tempRoot.cleanup();
|
|
});
|
|
|
|
it("creates a bootable token-only cell config and returns the secret-bearing result", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
now: () => 1_700_000_000_000,
|
|
generateToken: () => "gw-token",
|
|
});
|
|
|
|
const result = await service.create({
|
|
tenant: "acme",
|
|
env: ["FEATURE=a=b"],
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
tenant: "acme",
|
|
containerName: "openclaw-cell-acme",
|
|
port: 19_100,
|
|
image: "ghcr.io/openclaw/openclaw:latest",
|
|
runtime: "docker",
|
|
started: true,
|
|
token: "gw-token",
|
|
tokenNote: "Shown once. Store this Gateway token securely.",
|
|
url: "http://127.0.0.1:19100",
|
|
nextStep:
|
|
"Open http://127.0.0.1:19100, then configure per-tenant channel accounts inside the cell.",
|
|
});
|
|
expect(containers.run).toHaveBeenCalledOnce();
|
|
const [profile, start] = containers.run.mock.calls[0] ?? [];
|
|
expect(start).toBe(false);
|
|
expect(containers.createNetwork).toHaveBeenCalledWith(
|
|
"docker",
|
|
"openclaw-cell-acme-net",
|
|
{
|
|
"openclaw.fleet.tenant": "acme",
|
|
"openclaw.fleet.owner": cellOwnerId(path.join(root, "fleet", "cells", "acme")),
|
|
"openclaw.fleet.attempt": expect.stringMatching(/^[a-f0-9]{32}$/u),
|
|
},
|
|
{ internal: false },
|
|
);
|
|
expect(containers.start).toHaveBeenCalledWith("docker", "openclaw-cell-acme");
|
|
expect(profile?.networkName).toBe("openclaw-cell-acme-net");
|
|
expect(containers.createNetwork.mock.invocationCallOrder[0] ?? -1).toBeLessThan(
|
|
containers.run.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
|
);
|
|
expect(containers.run.mock.invocationCallOrder[0] ?? -1).toBeLessThan(
|
|
containers.start.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
|
);
|
|
expect(profile?.environment).toMatchObject({
|
|
OPENCLAW_GATEWAY_TOKEN: "gw-token",
|
|
FEATURE: "a=b",
|
|
});
|
|
|
|
const dataDir = path.join(root, "fleet", "cells", "acme");
|
|
const config = JSON.parse(await fs.readFile(path.join(dataDir, "openclaw.json"), "utf8")) as {
|
|
gateway?: {
|
|
mode?: string;
|
|
bind?: string;
|
|
auth?: Record<string, unknown>;
|
|
controlUi?: { allowedOrigins?: string[] };
|
|
};
|
|
};
|
|
expect(config.gateway).toMatchObject({
|
|
mode: "local",
|
|
bind: "lan",
|
|
auth: { mode: "token" },
|
|
controlUi: {
|
|
allowedOrigins: ["http://localhost:19100", "http://127.0.0.1:19100"],
|
|
},
|
|
});
|
|
expect(config.gateway?.auth).not.toHaveProperty("token");
|
|
const authSecretDir = cellAuthSecretDir(root, "acme");
|
|
await expect(fs.stat(authSecretDir)).resolves.toBeDefined();
|
|
expect(path.relative(dataDir, authSecretDir)).toMatch(/^\.\./u);
|
|
});
|
|
|
|
it("generates a 32-character hexadecimal token", async () => {
|
|
const containers = createContainerMock();
|
|
const result = await createFleetService({ env, containers: containers.runtime }).create({
|
|
tenant: "random-token",
|
|
start: false,
|
|
});
|
|
|
|
expect(result.token).toMatch(/^[a-f0-9]{32}$/u);
|
|
});
|
|
|
|
it("health-gates started creates and skips the gate with --no-start", async () => {
|
|
const containers = createContainerMock();
|
|
const fetchMock = vi.fn<typeof fetch>(async () => new Response(null, { status: 200 }));
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
fetch: fetchMock,
|
|
probePort: async () => true,
|
|
});
|
|
|
|
await expect(
|
|
service.create({ tenant: "healthy", gatewayToken: "token" }),
|
|
).resolves.toMatchObject({ tenant: "healthy", started: true });
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
|
|
fetchMock.mockClear();
|
|
await expect(
|
|
service.create({ tenant: "stopped", gatewayToken: "token", start: false }),
|
|
).resolves.toMatchObject({ tenant: "stopped", started: false });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps an unhealthy created cell and its runtime evidence", async () => {
|
|
const containers = createContainerMock();
|
|
let clock = 0;
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
fetch: vi.fn<typeof fetch>(async () => new Response(null, { status: 503 })),
|
|
now: () => (clock += 50_000),
|
|
sleep: async () => {},
|
|
probePort: async () => true,
|
|
});
|
|
|
|
await expect(service.create({ tenant: "sick", gatewayToken: "token" })).rejects.toThrow(
|
|
"Fleet cell sick was created but did not become healthy within 60s; inspect it with `openclaw fleet status sick` or `openclaw fleet logs sick`, or remove it with `openclaw fleet rm sick --force`.",
|
|
);
|
|
|
|
expect(getFleetCell(env, "sick")).toBeDefined();
|
|
expect(containers.remove).not.toHaveBeenCalled();
|
|
expect(containers.removeNetwork).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects busy explicit ports before reservation and propagates probe errors", async () => {
|
|
const containers = createContainerMock();
|
|
const busy = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
probePort: async () => false,
|
|
});
|
|
await expect(
|
|
busy.create({ tenant: "busy", port: 20_000, gatewayToken: "token" }),
|
|
).rejects.toThrow("Host port 20000 is already in use on 127.0.0.1 by another process.");
|
|
expect(getFleetCell(env, "busy")).toBeUndefined();
|
|
|
|
const failure = new Error("bind permission denied");
|
|
const broken = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
probePort: async () => {
|
|
throw failure;
|
|
},
|
|
});
|
|
await expect(broken.create({ tenant: "broken", gatewayToken: "token" })).rejects.toBe(failure);
|
|
expect(getFleetCell(env, "broken")).toBeUndefined();
|
|
});
|
|
|
|
it("skips probe-busy ports during automatic allocation", async () => {
|
|
const containers = createContainerMock();
|
|
const probePort = vi.fn(async (port: number) => port !== 19_100);
|
|
const service = createFleetService({ env, containers: containers.runtime, probePort });
|
|
|
|
const result = await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
|
|
expect(probePort.mock.calls.map(([port]) => port)).toEqual([19_100, 19_101]);
|
|
expect(result.port).toBe(19_101);
|
|
expect(getFleetCell(env, "acme")?.hostPort).toBe(19_101);
|
|
});
|
|
|
|
it("keeps scanning past long busy runs instead of capping attempts", async () => {
|
|
const containers = createContainerMock();
|
|
const probePort = vi.fn(async (port: number) => port >= 19_130);
|
|
const service = createFleetService({ env, containers: containers.runtime, probePort });
|
|
|
|
const result = await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
|
|
expect(result.port).toBe(19_130);
|
|
expect(probePort).toHaveBeenCalledTimes(31);
|
|
});
|
|
|
|
it("retries automatic allocation when another tenant reserves the probed port", async () => {
|
|
const containers = createContainerMock();
|
|
let releaseFirstProbes: (() => void) | undefined;
|
|
let firstProbeCount = 0;
|
|
const firstProbes = new Promise<void>((resolve) => {
|
|
releaseFirstProbes = resolve;
|
|
});
|
|
const probePort = vi.fn(async (port: number) => {
|
|
if (port === 19_100 && (firstProbeCount += 1) < 2) {
|
|
await firstProbes;
|
|
} else if (port === 19_100) {
|
|
releaseFirstProbes?.();
|
|
}
|
|
return true;
|
|
});
|
|
const service = createFleetService({ env, containers: containers.runtime, probePort });
|
|
|
|
const [alpha, beta] = await Promise.all([
|
|
service.create({ tenant: "alpha", gatewayToken: "alpha-token" }),
|
|
service.create({ tenant: "beta", gatewayToken: "beta-token" }),
|
|
]);
|
|
|
|
expect(new Set([alpha.port, beta.port])).toEqual(new Set([19_100, 19_101]));
|
|
expect(
|
|
listFleetCells(env)
|
|
.map((cell) => cell.hostPort)
|
|
.toSorted((left, right) => left - right),
|
|
).toEqual([19_100, 19_101]);
|
|
});
|
|
|
|
it("threads disk and Podman internal networking into provisioning", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
fetch: vi.fn<typeof fetch>(async () => new Response(null, { status: 200 })),
|
|
now: () => 1000,
|
|
});
|
|
await service.create({
|
|
tenant: "acme",
|
|
runtime: "podman",
|
|
disk: "10g",
|
|
network: "internal",
|
|
gatewayToken: "token",
|
|
});
|
|
expect(containers.run.mock.calls[0]?.[0]).toMatchObject({ diskSize: "10g" });
|
|
expect(containers.createNetwork).toHaveBeenCalledWith(
|
|
"podman",
|
|
"openclaw-cell-acme-net",
|
|
expect.any(Object),
|
|
{ internal: true },
|
|
);
|
|
});
|
|
|
|
it("rejects Docker internal networking before reservation", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime });
|
|
await expect(
|
|
service.create({ tenant: "acme", network: "internal", gatewayToken: "token" }),
|
|
).rejects.toThrow(/Docker cannot publish loopback ports/iu);
|
|
expect(getFleetCell(env, "acme")).toBeUndefined();
|
|
expect(containers.createNetwork).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("wraps unsupported disk errors after rolling back the reservation", async () => {
|
|
const containers = createContainerMock();
|
|
containers.run.mockRejectedValue(new Error("--storage-opt is supported only with pquota"));
|
|
const service = createFleetService({ env, containers: containers.runtime });
|
|
await expect(
|
|
service.create({ tenant: "acme", disk: "10g", gatewayToken: "token" }),
|
|
).rejects.toThrow(/Fleet cannot enforce --disk.*XFS/iu);
|
|
expect(getFleetCell(env, "acme")).toBeUndefined();
|
|
});
|
|
|
|
it("rejects a remote runtime before registry or filesystem mutation", async () => {
|
|
const containers = createContainerMock();
|
|
containers.assertLocal.mockRejectedValue(
|
|
new Error("Fleet requires a local Docker endpoint; remote cells are not supported."),
|
|
);
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
|
|
await expect(service.create({ tenant: "remote", gatewayToken: "token" })).rejects.toThrow(
|
|
/local Docker endpoint.*remote cells/iu,
|
|
);
|
|
|
|
expect(getFleetCell(env, "remote")).toBeUndefined();
|
|
expect(containers.createNetwork).not.toHaveBeenCalled();
|
|
expect(containers.run).not.toHaveBeenCalled();
|
|
await expect(fs.stat(path.join(root, "fleet", "cells", "remote"))).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
|
|
it("lists cells deterministically and degrades runtime failures to unknown", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "zulu", gatewayToken: "z-token" });
|
|
await service.create({ tenant: "alpha", gatewayToken: "a-token" });
|
|
containers.inspect.mockImplementation(async (_runtime, name) =>
|
|
name.endsWith("alpha")
|
|
? runningInspection({ labels: fleetLabels("alpha") })
|
|
: { kind: "unavailable", state: "unknown", error: "daemon unavailable" },
|
|
);
|
|
|
|
const cells = await service.list();
|
|
|
|
expect(cells.map((cell) => [cell.tenant, cell.state])).toEqual([
|
|
["alpha", "running"],
|
|
["zulu", "unknown"],
|
|
]);
|
|
expect(JSON.stringify(cells)).not.toContain("old-token");
|
|
});
|
|
|
|
it("reports live status with a bounded loopback health probe", async () => {
|
|
const containers = createContainerMock();
|
|
const fetchMock = vi.fn<typeof fetch>(async () => new Response(null, { status: 200 }));
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
fetch: fetchMock,
|
|
now: () => 1000,
|
|
});
|
|
await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
containers.inspect.mockResolvedValue(runningInspection());
|
|
|
|
const status = await service.status("acme");
|
|
|
|
expect(status.container).toEqual({
|
|
state: "running",
|
|
running: true,
|
|
managed: true,
|
|
imageId: "sha256:old-image-id",
|
|
});
|
|
expect(status.health).toEqual({
|
|
status: "ok",
|
|
url: "http://127.0.0.1:19100/healthz",
|
|
httpStatus: 200,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"http://127.0.0.1:19100/healthz",
|
|
expect.objectContaining({ method: "GET", redirect: "manual" }),
|
|
);
|
|
expect(JSON.stringify(status)).not.toContain("old-token");
|
|
});
|
|
|
|
it("reports failed and skipped health outcomes without probing a stopped cell", async () => {
|
|
const containers = createContainerMock();
|
|
const fetchMock = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
|
.mockResolvedValue(new Response(null, { status: 503 }));
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
fetch: fetchMock,
|
|
now: () => 1000,
|
|
});
|
|
await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
fetchMock.mockClear();
|
|
containers.inspect.mockResolvedValue(runningInspection({ state: "exited", running: false }));
|
|
|
|
await expect(service.status("acme")).resolves.toMatchObject({
|
|
health: { status: "skipped", reason: "container is not running" },
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
|
|
containers.inspect.mockResolvedValue(runningInspection());
|
|
await expect(service.status("acme")).resolves.toMatchObject({
|
|
health: { status: "failed", httpStatus: 503, error: "HTTP 503" },
|
|
});
|
|
});
|
|
|
|
it("omits imageId for missing and unmanaged status", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
|
|
containers.inspect.mockResolvedValue(runningInspection({ labels: {} }));
|
|
expect((await service.status("acme")).container).not.toHaveProperty("imageId");
|
|
containers.inspect.mockResolvedValue({ kind: "missing", state: "missing" });
|
|
expect((await service.status("acme")).container).not.toHaveProperty("imageId");
|
|
});
|
|
|
|
it.each(["start", "stop", "restart"] as const)("runs the %s lifecycle action", async (action) => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
containers.inspect.mockResolvedValue(runningInspection());
|
|
containers[action].mockClear();
|
|
|
|
await expect(service.lifecycle("acme", action)).resolves.toEqual({ tenant: "acme", action });
|
|
|
|
expect(containers[action]).toHaveBeenCalledWith("docker", "openclaw-cell-acme");
|
|
});
|
|
|
|
it("streams logs only after proving container ownership", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
containers.inspect.mockResolvedValue(runningInspection());
|
|
|
|
await expect(
|
|
service.logs({ tenant: "acme", follow: true, tail: 100, since: "10m" }),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(containers.logs).toHaveBeenCalledWith("docker", "openclaw-cell-acme", {
|
|
follow: true,
|
|
tail: 100,
|
|
since: "10m",
|
|
redactValues: ["old-token"],
|
|
});
|
|
});
|
|
|
|
it.each(["foreign", "missing", "unavailable"] as const)(
|
|
"refuses %s log inspection before streaming",
|
|
async (kind) => {
|
|
const inspection: FleetContainerInspectResult =
|
|
kind === "foreign"
|
|
? runningInspection({ labels: {} })
|
|
: kind === "missing"
|
|
? { kind: "missing", state: "missing" }
|
|
: { kind: "unavailable", state: "unknown", error: "daemon unavailable" };
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
containers.inspect.mockResolvedValue(inspection);
|
|
|
|
await expect(service.logs({ tenant: "acme" })).rejects.toThrow();
|
|
expect(containers.logs).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("refuses logs when the recorded runtime is unavailable", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
containers.inspect.mockClear();
|
|
containers.assertLocal.mockRejectedValue(new Error("daemon unavailable"));
|
|
|
|
await expect(service.logs({ tenant: "acme" })).rejects.toThrow(/daemon unavailable/iu);
|
|
expect(containers.inspect).not.toHaveBeenCalled();
|
|
expect(containers.logs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("carries inspected environment and resources through upgrade", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
fetch: vi.fn<typeof fetch>(async () => new Response(null, { status: 200 })),
|
|
now: () => 1000,
|
|
generateAttemptId: () => NEXT_ATTEMPT_ID,
|
|
});
|
|
await service.create({ tenant: "acme", gatewayToken: "old-token" });
|
|
containers.run.mockClear();
|
|
// The disk limit replays from the fleet label because Podman inspect has no
|
|
// HostConfig.StorageOpt; the label is the cross-runtime carrier.
|
|
const diskLabels = { ...fleetLabels(), "openclaw.fleet.disk-limit": "10g" };
|
|
containers.inspect
|
|
.mockResolvedValue(runningInspection({ labels: diskLabels }))
|
|
.mockResolvedValueOnce(runningInspection({ labels: diskLabels }))
|
|
.mockResolvedValueOnce(runningInspection({ labels: fleetLabels("acme", NEXT_ATTEMPT_ID) }));
|
|
|
|
const result = await service.upgrade("acme", "ghcr.io/openclaw/openclaw:v2");
|
|
|
|
expect(result).toEqual({
|
|
tenant: "acme",
|
|
action: "upgrade",
|
|
image: "ghcr.io/openclaw/openclaw:v2",
|
|
});
|
|
expect(containers.pull).toHaveBeenCalledWith("docker", "ghcr.io/openclaw/openclaw:v2");
|
|
expect(containers.stop).toHaveBeenCalledWith("docker", "openclaw-cell-acme");
|
|
expect(containers.remove).toHaveBeenCalledWith("docker", "openclaw-cell-acme", false);
|
|
expect(containers.inspectNetwork).toHaveBeenCalledWith("docker", "openclaw-cell-acme-net");
|
|
const [profile, start] = containers.run.mock.calls[0] ?? [];
|
|
expect(start).toBe(true);
|
|
expect(profile).toMatchObject({
|
|
image: "ghcr.io/openclaw/openclaw:v2",
|
|
hostPort: 19_100,
|
|
memory: "2147483648",
|
|
cpus: "2",
|
|
pidsLimit: 512,
|
|
diskSize: "10g",
|
|
networkName: "openclaw-cell-acme-net",
|
|
environment: {
|
|
HOME: "/home/node",
|
|
OPENCLAW_GATEWAY_TOKEN: "old-token",
|
|
FEATURE: "enabled",
|
|
},
|
|
});
|
|
expect(profile?.environment).not.toHaveProperty("NODE_VERSION");
|
|
expect(getFleetCell(env, "acme")?.image).toBe("ghcr.io/openclaw/openclaw:v2");
|
|
});
|
|
|
|
it("passes digest-pinned images verbatim to create and upgrade", async () => {
|
|
const containers = createContainerMock();
|
|
const digest = `ghcr.io/openclaw/openclaw@sha256:${"a".repeat(64)}`;
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
generateAttemptId: () => NEXT_ATTEMPT_ID,
|
|
});
|
|
|
|
await service.create({ tenant: "acme", image: digest, gatewayToken: "old-token" });
|
|
expect(containers.run.mock.calls[0]?.[0].image).toBe(digest);
|
|
|
|
containers.run.mockClear();
|
|
containers.inspect
|
|
.mockResolvedValueOnce(runningInspection())
|
|
.mockResolvedValueOnce(runningInspection({ labels: fleetLabels("acme", NEXT_ATTEMPT_ID) }));
|
|
await service.upgrade("acme");
|
|
|
|
expect(containers.pull).toHaveBeenCalledWith("docker", digest);
|
|
expect(containers.run.mock.calls[0]?.[0].image).toBe(digest);
|
|
});
|
|
|
|
it("restores the immutable old image when replacement fails", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "acme", gatewayToken: "old-token" });
|
|
containers.run.mockClear();
|
|
containers.inspect
|
|
.mockResolvedValueOnce(runningInspection())
|
|
.mockResolvedValueOnce({ kind: "missing", state: "missing" });
|
|
containers.run.mockRejectedValueOnce(new Error("replacement failed")).mockResolvedValueOnce();
|
|
|
|
await expect(service.upgrade("acme")).rejects.toThrow(/previous container was restored/iu);
|
|
|
|
expect(containers.run).toHaveBeenCalledTimes(2);
|
|
expect(containers.run.mock.calls[1]?.[0].image).toBe("sha256:old-image-id");
|
|
expect(getFleetCell(env, "acme")?.image).toBe("ghcr.io/openclaw/openclaw:latest");
|
|
});
|
|
|
|
it("restarts the old cell when removal fails after stop", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "acme", gatewayToken: "old-token" });
|
|
containers.run.mockClear();
|
|
containers.start.mockClear();
|
|
containers.inspect
|
|
.mockResolvedValueOnce(runningInspection())
|
|
.mockResolvedValueOnce(runningInspection({ state: "exited", running: false }));
|
|
containers.remove.mockRejectedValueOnce(new Error("daemon busy"));
|
|
|
|
await expect(service.upgrade("acme")).rejects.toThrow(/previous container was restored/iu);
|
|
|
|
expect(containers.stop).toHaveBeenCalledWith("docker", "openclaw-cell-acme");
|
|
expect(containers.start).toHaveBeenCalledWith("docker", "openclaw-cell-acme");
|
|
expect(containers.run).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores the old cell when the replacement registry update fails", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
fetch: vi.fn<typeof fetch>(async () => new Response(null, { status: 200 })),
|
|
now: () => 1000,
|
|
generateAttemptId: () => NEXT_ATTEMPT_ID,
|
|
updateImage: () => {
|
|
throw new Error("state database is full");
|
|
},
|
|
});
|
|
await service.create({ tenant: "acme", gatewayToken: "old-token" });
|
|
containers.run.mockClear();
|
|
containers.remove.mockClear();
|
|
containers.inspect
|
|
.mockResolvedValueOnce(runningInspection())
|
|
.mockResolvedValueOnce(runningInspection({ labels: fleetLabels("acme", NEXT_ATTEMPT_ID) }))
|
|
.mockResolvedValueOnce(runningInspection({ labels: fleetLabels("acme", NEXT_ATTEMPT_ID) }));
|
|
|
|
await expect(service.upgrade("acme")).rejects.toThrow(/previous container was restored/iu);
|
|
|
|
expect(containers.run).toHaveBeenCalledTimes(2);
|
|
expect(containers.run.mock.calls[1]?.[0].image).toBe("sha256:old-image-id");
|
|
expect(containers.remove).toHaveBeenCalledWith("docker", "openclaw-cell-acme", true);
|
|
expect(containers.removeNetwork).not.toHaveBeenCalled();
|
|
expect(getFleetCell(env, "acme")?.image).toBe("ghcr.io/openclaw/openclaw:latest");
|
|
});
|
|
|
|
it("restores the previous cell when the replacement container is not running", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
now: () => 1000,
|
|
generateAttemptId: () => NEXT_ATTEMPT_ID,
|
|
});
|
|
await service.create({ tenant: "acme", gatewayToken: "old-token" });
|
|
containers.run.mockClear();
|
|
const crashLooping = runningInspection({
|
|
labels: fleetLabels("acme", NEXT_ATTEMPT_ID),
|
|
state: "restarting",
|
|
running: false,
|
|
});
|
|
containers.inspect
|
|
.mockResolvedValueOnce(runningInspection())
|
|
.mockResolvedValueOnce(crashLooping)
|
|
.mockResolvedValueOnce(crashLooping);
|
|
|
|
await expect(service.upgrade("acme")).rejects.toThrow(/previous container was restored/iu);
|
|
|
|
expect(containers.run).toHaveBeenCalledTimes(2);
|
|
expect(containers.run.mock.calls[1]?.[0].image).toBe("sha256:old-image-id");
|
|
expect(getFleetCell(env, "acme")?.image).toBe("ghcr.io/openclaw/openclaw:latest");
|
|
});
|
|
|
|
it("restores the previous cell when the replacement crashes after starting", async () => {
|
|
const containers = createContainerMock();
|
|
const fetchMock = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
|
.mockRejectedValue(new Error("connect ECONNREFUSED"));
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
fetch: fetchMock,
|
|
sleep: async () => {},
|
|
now: () => 1000,
|
|
generateAttemptId: () => NEXT_ATTEMPT_ID,
|
|
});
|
|
await service.create({ tenant: "acme", gatewayToken: "old-token" });
|
|
containers.run.mockClear();
|
|
const crashed = runningInspection({
|
|
labels: fleetLabels("acme", NEXT_ATTEMPT_ID),
|
|
state: "exited",
|
|
running: false,
|
|
});
|
|
containers.inspect
|
|
.mockResolvedValueOnce(runningInspection())
|
|
.mockResolvedValueOnce(runningInspection({ labels: fleetLabels("acme", NEXT_ATTEMPT_ID) }))
|
|
.mockResolvedValueOnce(crashed)
|
|
.mockResolvedValueOnce(crashed);
|
|
|
|
await expect(service.upgrade("acme")).rejects.toThrow(/previous container was restored/iu);
|
|
|
|
expect(containers.run).toHaveBeenCalledTimes(2);
|
|
expect(containers.run.mock.calls[1]?.[0].image).toBe("sha256:old-image-id");
|
|
expect(getFleetCell(env, "acme")?.image).toBe("ghcr.io/openclaw/openclaw:latest");
|
|
});
|
|
|
|
it("restores the previous cell when the replacement never becomes healthy", async () => {
|
|
const containers = createContainerMock();
|
|
let clock = 0;
|
|
const fetchMock = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
|
.mockResolvedValue(new Response(null, { status: 503 }));
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
fetch: fetchMock,
|
|
sleep: async () => {},
|
|
now: () => (clock += 50_000),
|
|
generateAttemptId: () => NEXT_ATTEMPT_ID,
|
|
});
|
|
await service.create({ tenant: "acme", gatewayToken: "old-token" });
|
|
containers.run.mockClear();
|
|
const hung = runningInspection({ labels: fleetLabels("acme", NEXT_ATTEMPT_ID) });
|
|
containers.inspect
|
|
.mockResolvedValueOnce(runningInspection())
|
|
.mockResolvedValueOnce(hung)
|
|
.mockResolvedValueOnce(hung)
|
|
.mockResolvedValueOnce(hung);
|
|
|
|
await expect(service.upgrade("acme")).rejects.toThrow(/previous container was restored/iu);
|
|
|
|
expect(containers.run).toHaveBeenCalledTimes(2);
|
|
expect(containers.run.mock.calls[1]?.[0].image).toBe("sha256:old-image-id");
|
|
expect(getFleetCell(env, "acme")?.image).toBe("ghcr.io/openclaw/openclaw:latest");
|
|
});
|
|
|
|
it("refuses upgrade before pull or removal when the inspected token is missing", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "acme", gatewayToken: "old-token" });
|
|
containers.inspect.mockResolvedValue(
|
|
runningInspection({ environment: { HOME: "/home/node" } }),
|
|
);
|
|
|
|
await expect(service.upgrade("acme")).rejects.toThrow(/no Gateway token environment/iu);
|
|
expect(containers.pull).not.toHaveBeenCalled();
|
|
expect(containers.stop).not.toHaveBeenCalled();
|
|
expect(containers.remove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses upgrade when an unexpected container is attached to the cell network", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "acme", gatewayToken: "old-token" });
|
|
containers.inspect.mockResolvedValue(runningInspection());
|
|
containers.inspectNetwork.mockResolvedValue({
|
|
kind: "ok",
|
|
labels: fleetLabels(),
|
|
attachedContainers: [
|
|
{ id: "cell-id", name: "openclaw-cell-acme" },
|
|
{ id: "peer-id", name: "unexpected-peer" },
|
|
],
|
|
internal: false,
|
|
});
|
|
|
|
await expect(service.upgrade("acme")).rejects.toThrow(/unexpected containers/iu);
|
|
expect(containers.pull).toHaveBeenCalledOnce();
|
|
expect(containers.stop).not.toHaveBeenCalled();
|
|
expect(containers.remove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects option-like images before create or upgrade mutations", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
|
|
await expect(
|
|
service.create({ tenant: "bad-image", image: "--help", gatewayToken: "token" }),
|
|
).rejects.toThrow(/image must not begin/iu);
|
|
expect(getFleetCell(env, "bad-image")).toBeUndefined();
|
|
expect(containers.run).not.toHaveBeenCalled();
|
|
|
|
await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
containers.inspect.mockResolvedValue(runningInspection());
|
|
await expect(service.upgrade("acme", "--help")).rejects.toThrow(/image must not begin/iu);
|
|
expect(containers.pull).not.toHaveBeenCalled();
|
|
expect(containers.stop).not.toHaveBeenCalled();
|
|
expect(containers.remove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("requires force for running removal and purge", async () => {
|
|
const containers = createContainerMock();
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
await service.create({ tenant: "acme", gatewayToken: "token" });
|
|
containers.inspect.mockResolvedValue(runningInspection());
|
|
|
|
await expect(service.remove({ tenant: "acme" })).rejects.toThrow(/running.*--force/iu);
|
|
await expect(service.remove({ tenant: "acme", purgeData: true })).rejects.toThrow(
|
|
"--purge-data requires --force.",
|
|
);
|
|
expect(containers.remove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("removes a labeled partial container before releasing a failed create", async () => {
|
|
const containers = createContainerMock(runningInspection());
|
|
containers.run.mockRejectedValue(new Error("host port is already allocated"));
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
now: () => 1000,
|
|
generateAttemptId: () => TEST_ATTEMPT_ID,
|
|
});
|
|
|
|
await expect(service.create({ tenant: "acme", gatewayToken: "token" })).rejects.toThrow(
|
|
/already allocated/iu,
|
|
);
|
|
|
|
expect(containers.remove).toHaveBeenCalledWith("docker", "openclaw-cell-acme", true);
|
|
expect(getFleetCell(env, "acme")).toBeUndefined();
|
|
});
|
|
|
|
it("retains a failed-create reservation when partial cleanup is uncertain", async () => {
|
|
const containers = createContainerMock({
|
|
kind: "unavailable",
|
|
state: "unknown",
|
|
error: "daemon unavailable",
|
|
});
|
|
containers.run.mockRejectedValue(new Error("container command timed out"));
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
|
|
await expect(service.create({ tenant: "acme", gatewayToken: "token" })).rejects.toThrow(
|
|
/timed out/iu,
|
|
);
|
|
|
|
expect(containers.remove).not.toHaveBeenCalled();
|
|
expect(getFleetCell(env, "acme")).toBeDefined();
|
|
});
|
|
|
|
it("cleans up its exact-attempt network when network creation fails", async () => {
|
|
const containers = createContainerMock();
|
|
containers.createNetwork.mockRejectedValue(new Error("network create timed out"));
|
|
containers.inspectNetwork
|
|
.mockResolvedValueOnce({
|
|
kind: "ok",
|
|
labels: fleetLabels(),
|
|
attachedContainers: [],
|
|
internal: false,
|
|
})
|
|
.mockResolvedValueOnce({ kind: "missing" });
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
now: () => 1000,
|
|
generateAttemptId: () => TEST_ATTEMPT_ID,
|
|
});
|
|
|
|
await expect(service.create({ tenant: "acme", gatewayToken: "token" })).rejects.toThrow(
|
|
/timed out/iu,
|
|
);
|
|
|
|
expect(containers.run).not.toHaveBeenCalled();
|
|
expect(containers.removeNetwork).toHaveBeenCalledWith("docker", "openclaw-cell-acme-net");
|
|
expect(getFleetCell(env, "acme")).toBeUndefined();
|
|
});
|
|
|
|
it("serializes same-tenant mutations across service instances", async () => {
|
|
const containers = createContainerMock();
|
|
let releaseNetwork: (() => void) | undefined;
|
|
containers.createNetwork.mockImplementation(
|
|
async () =>
|
|
await new Promise<void>((resolve) => {
|
|
releaseNetwork = resolve;
|
|
}),
|
|
);
|
|
const first = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
const second = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
|
|
const creating = first.create({ tenant: "acme", gatewayToken: "token" });
|
|
await vi.waitFor(() => expect(containers.createNetwork).toHaveBeenCalledOnce());
|
|
await expect(second.create({ tenant: "acme", gatewayToken: "other-token" })).rejects.toThrow(
|
|
/fleet create.*already running/iu,
|
|
);
|
|
|
|
releaseNetwork?.();
|
|
await expect(creating).resolves.toMatchObject({ tenant: "acme" });
|
|
});
|
|
|
|
it("releases a failed operation lease for a retry", async () => {
|
|
const containers = createContainerMock();
|
|
containers.createNetwork.mockRejectedValueOnce(new Error("daemon busy"));
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
|
|
await expect(service.create({ tenant: "acme", gatewayToken: "token" })).rejects.toThrow(
|
|
/daemon busy/iu,
|
|
);
|
|
await expect(
|
|
service.create({ tenant: "acme", gatewayToken: "retry-token" }),
|
|
).resolves.toMatchObject({ tenant: "acme" });
|
|
});
|
|
|
|
it("removes its exact-attempt container when the reservation disappears mid-create", async () => {
|
|
const containers = createContainerMock(runningInspection({ state: "created", running: false }));
|
|
containers.run.mockImplementation(async () => {
|
|
deleteFleetCell(env, "acme");
|
|
});
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
now: () => 1000,
|
|
generateAttemptId: () => TEST_ATTEMPT_ID,
|
|
});
|
|
|
|
await expect(service.create({ tenant: "acme", gatewayToken: "token" })).rejects.toThrow(
|
|
/reservation changed/iu,
|
|
);
|
|
|
|
expect(containers.remove).toHaveBeenCalledWith("docker", "openclaw-cell-acme", true);
|
|
expect(containers.start).not.toHaveBeenCalled();
|
|
expect(getFleetCell(env, "acme")).toBeUndefined();
|
|
});
|
|
|
|
it("releases the reservation when an unlabeled foreign container holds the cell name", async () => {
|
|
const containers = createContainerMock(runningInspection({ labels: {} }));
|
|
containers.run.mockRejectedValue(new Error("container name is already in use"));
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
|
|
await expect(service.create({ tenant: "acme", gatewayToken: "token" })).rejects.toThrow(
|
|
/already in use/iu,
|
|
);
|
|
|
|
expect(containers.remove).not.toHaveBeenCalled();
|
|
expect(getFleetCell(env, "acme")).toBeUndefined();
|
|
});
|
|
|
|
it("releases the reservation when an unlabeled foreign network holds the cell name", async () => {
|
|
const containers = createContainerMock();
|
|
containers.createNetwork.mockRejectedValue(new Error("network name is already in use"));
|
|
containers.inspectNetwork.mockResolvedValue({
|
|
kind: "ok",
|
|
labels: {},
|
|
attachedContainers: [],
|
|
internal: false,
|
|
});
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
|
|
await expect(service.create({ tenant: "acme", gatewayToken: "token" })).rejects.toThrow(
|
|
/already in use/iu,
|
|
);
|
|
|
|
expect(containers.removeNetwork).not.toHaveBeenCalled();
|
|
expect(getFleetCell(env, "acme")).toBeUndefined();
|
|
});
|
|
|
|
it("never removes a same-tenant container owned by another profile", async () => {
|
|
const containers = createContainerMock(
|
|
runningInspection({
|
|
labels: {
|
|
...fleetLabels(),
|
|
"openclaw.fleet.owner": "11111111111111111111111111111111",
|
|
},
|
|
}),
|
|
);
|
|
containers.run.mockRejectedValue(new Error("container name is already in use"));
|
|
const service = createFleetService({ env, containers: containers.runtime, now: () => 1000 });
|
|
|
|
await expect(service.create({ tenant: "acme", gatewayToken: "token" })).rejects.toThrow(
|
|
/already in use/iu,
|
|
);
|
|
|
|
expect(containers.remove).not.toHaveBeenCalled();
|
|
expect(getFleetCell(env, "acme")).toBeUndefined();
|
|
});
|
|
|
|
it("never removes a same-profile container that predates the create attempt", async () => {
|
|
const containers = createContainerMock(
|
|
runningInspection({
|
|
labels: fleetLabels("acme", "33333333333333333333333333333333"),
|
|
}),
|
|
);
|
|
containers.run.mockRejectedValue(new Error("container name is already in use"));
|
|
const service = createFleetService({
|
|
env,
|
|
containers: containers.runtime,
|
|
now: () => 1000,
|
|
generateAttemptId: () => TEST_ATTEMPT_ID,
|
|
});
|
|
|
|
await expect(service.create({ tenant: "acme", gatewayToken: "token" })).rejects.toThrow(
|
|
/already in use/iu,
|
|
);
|
|
|
|
expect(containers.remove).not.toHaveBeenCalled();
|
|
expect(getFleetCell(env, "acme")).toBeDefined();
|
|
});
|
|
});
|