mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -06:00
11ea4df29c
* 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
200 lines
6.1 KiB
TypeScript
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.");
|
|
});
|
|
});
|