Files
openclaw/src/cli/fleet-cli/commands.runtime.test.ts
T
Peter Steinberger 11ea4df29c feat(fleet): per-cell disk limits, egress policy, backup/restore, and cell diagnostics (#104828)
* feat(fleet): per-cell disk limits, egress policy, backup/restore, logs, and doctor

- fleet create --disk <size> caps the container writable layer via --storage-opt;
  unsupported storage backends fail create with an actionable support-matrix
  error, and the limit replays across upgrade/restore through a fleet-owned
  container label because Podman inspect has no HostConfig.StorageOpt.
- fleet create --network bridge|internal adds an opt-in no-egress mode on
  Podman (published loopback port keeps working, verified live); Docker
  internal cells are rejected fail-closed because Docker does not publish
  loopback ports on internal networks.
- fleet backup/restore: per-tenant 0600 tar archives with manifest tenant
  binding, symlink/hardlink rejection, byte and path-segment budgets,
  root-bounded extraction, atomic no-overwrite publish, lease fencing, and
  Gateway token rotation on restore; failures preserve displaced data and
  never leave a force-stopped or half-started cell serving silently.
- fleet logs: ownership-asserted, bounded, token-redacted on both streams,
  with a generation re-check so a concurrent restore cannot leak a rotated
  token.
- fleet doctor: read-only per-cell audit of ownership labels, health,
  hardening drift, loopback port binding, token presence, network egress
  mode, and 0700 state-dir permissions; any failed finding exits nonzero.
  Cross-runtime checks are grounded in live-verified Docker 28/Podman 4.9
  inspect shapes (CapDrop vs EffectiveCaps, missing StorageOpt, missing
  network containers map).

Related: #104436 (v1 shipped in #104527)

* fix(fleet): harden streamed log redaction and root restore ownership

- redacting stream writer honors target backpressure, retains secret-prefix
  overlap across forced long-line flushes, and never splits a token between
  emitted chunks
- root-invoked restores repair ownership for explicit non-root user mappings
  instead of leaving root-owned 0700 state trees
- fleet logs merges the shipped --follow streaming surface (#104669) with the
  v1.1 token-redaction contract

* fix(fleet): stream partial log lines live and report phase-accurate restore recovery paths

- the redacting log writer emits safe text on every chunk (retaining only a
  possible token prefix) so unterminated progress output streams immediately
- restore failure notes distinguish pre-swap, displaced, and swapped states so
  operators recover the correct tree, and an unavailable runtime inspection is
  reported as an unverified replacement instead of silence

* fix(fleet): reject backslash archive paths, guard stderr pipes, and validate disk-limit labels in doctor

- restore/backup path rules reject literal backslashes so a tampered entry
  cannot validate as one path and extract as another on POSIX
- fleet logs handles broken pipes on stderr as well as stdout
- fleet doctor fails malformed disk-limit labels that would break
  upgrade/restore replay instead of reporting them as passing
2026-07-11 18:24:01 -07:00

200 lines
6.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = await vi.hoisted(async () => {
const { createCliRuntimeMock } = await import("../test-runtime-mock.js");
return {
...createCliRuntimeMock(vi),
create: vi.fn(),
list: vi.fn(),
status: vi.fn(),
logs: vi.fn(),
lifecycle: vi.fn(),
upgrade: vi.fn(),
remove: vi.fn(),
backup: vi.fn(),
restore: vi.fn(),
doctor: vi.fn(),
};
});
vi.mock("../../runtime.js", () => ({ defaultRuntime: mocks.defaultRuntime }));
vi.mock("../../fleet/service.runtime.js", () => ({
createFleetService: () => ({
create: mocks.create,
list: mocks.list,
status: mocks.status,
logs: mocks.logs,
lifecycle: mocks.lifecycle,
upgrade: mocks.upgrade,
remove: mocks.remove,
backup: mocks.backup,
restore: mocks.restore,
doctor: mocks.doctor,
}),
}));
import {
runFleetCreateCommand,
runFleetListCommand,
runFleetLogsCommand,
runFleetRemoveCommand,
runFleetStatusCommand,
runFleetBackupCommand,
runFleetRestoreCommand,
runFleetDoctorCommand,
} from "./commands.runtime.js";
describe("fleet command output", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.runtimeLogs.length = 0;
mocks.runtimeErrors.length = 0;
process.exitCode = undefined;
});
it("writes backup and restore JSON results", async () => {
const backup = {
tenant: "acme",
archivePath: "/tmp/a.tgz",
fileCount: 1,
skippedSymlinks: 0,
skippedSpecial: 0,
note: "secret note",
};
const restore = {
tenant: "acme",
archivePath: "/tmp/a.tgz",
token: "new-token",
tokenNote: "Shown once.",
started: false,
url: "http://127.0.0.1:19100",
};
mocks.backup.mockResolvedValue(backup);
mocks.restore.mockResolvedValue(restore);
await runFleetBackupCommand({ tenant: "acme", json: true });
await runFleetRestoreCommand({ tenant: "acme", from: "/tmp/a.tgz", force: false, json: true });
expect(mocks.defaultRuntime.writeJson).toHaveBeenNthCalledWith(1, backup);
expect(mocks.defaultRuntime.writeJson).toHaveBeenNthCalledWith(2, restore);
});
it("surfaces skipped symlink counts in human backup output", async () => {
mocks.backup.mockResolvedValue({
tenant: "acme",
archivePath: "/tmp/a.tgz",
fileCount: 3,
skippedSymlinks: 2,
skippedSpecial: 1,
note: "secret note",
});
await runFleetBackupCommand({ tenant: "acme", json: false });
expect(mocks.runtimeLogs.join("\n")).toContain("Skipped 2 symlink(s) and 1 special file(s)");
});
it("sets exitCode when doctor reports a failure", async () => {
const reports = [
{
tenant: "acme",
findings: [{ check: "port-binding", status: "fail", detail: "bad binding" }],
},
];
mocks.doctor.mockResolvedValue(reports);
await runFleetDoctorCommand({ tenant: "acme", json: true });
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(reports);
expect(process.exitCode).toBe(1);
});
it("writes the documented secret-bearing create JSON shape", async () => {
const result = {
tenant: "acme",
containerName: "openclaw-cell-acme",
port: 19_100,
image: "ghcr.io/openclaw/openclaw:latest",
runtime: "docker" as const,
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.",
};
mocks.create.mockResolvedValue(result);
await runFleetCreateCommand({ tenant: "acme", json: true });
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(result);
});
it("prints the Gateway token exactly once in human create output", async () => {
mocks.create.mockResolvedValue({
tenant: "acme",
containerName: "openclaw-cell-acme",
port: 19_100,
image: "image",
runtime: "docker",
started: true,
token: "one-token",
tokenNote: "Shown once. Store this Gateway token securely.",
url: "http://127.0.0.1:19100",
nextStep: "Open the cell.",
});
await runFleetCreateCommand({ tenant: "acme", json: false });
expect(mocks.runtimeLogs.join("\n").match(/one-token/gu)).toHaveLength(1);
expect(mocks.runtimeLogs.join("\n")).toContain("Shown once");
});
it("wraps deterministic list JSON in a cells object", async () => {
const cells = [
{
tenant: "acme",
state: "running",
port: 19_100,
image: "image",
created: "2026-01-01T00:00:00.000Z",
},
];
mocks.list.mockResolvedValue(cells);
await runFleetListCommand({ json: true });
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({ cells });
});
it("delegates logs without adding formatted output", async () => {
const options = { tenant: "acme", follow: true, tail: 100, since: "10m" };
await runFleetLogsCommand(options);
expect(mocks.logs).toHaveBeenCalledWith(options);
expect(mocks.defaultRuntime.log).not.toHaveBeenCalled();
expect(mocks.defaultRuntime.writeJson).not.toHaveBeenCalled();
});
it("writes status JSON and describes retained data on removal", async () => {
const status = {
tenant: "acme",
containerName: "openclaw-cell-acme",
runtime: "docker" as const,
port: 19_100,
image: "image",
created: "2026-01-01T00:00:00.000Z",
dataDir: "/tmp/acme",
container: { state: "running", running: true, managed: true },
health: {
status: "ok" as const,
url: "http://127.0.0.1:19100/healthz",
httpStatus: 200,
},
};
mocks.status.mockResolvedValue(status);
mocks.remove.mockResolvedValue({ tenant: "acme", action: "rm", dataPurged: false });
await runFleetStatusCommand({ tenant: "acme", json: true });
await runFleetRemoveCommand({ tenant: "acme", force: false, purgeData: false });
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(status);
expect(mocks.runtimeLogs).toContain("Removed fleet cell acme; data retained.");
});
});