mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-27 11:51:17 +00:00
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
allocateHostPort,
|
|
buildCellCreateArgs,
|
|
buildCellEnvironment,
|
|
buildCellRunArgs,
|
|
cellAuthSecretDir,
|
|
cellContainerName,
|
|
cellDataDir,
|
|
cellNetworkName,
|
|
cellOwnerId,
|
|
DEFAULT_FLEET_IMAGE,
|
|
FLEET_ATTEMPT_LABEL,
|
|
FLEET_ENV_KEYS_LABEL,
|
|
FLEET_GATEWAY_PORT,
|
|
FLEET_OWNER_LABEL,
|
|
FLEET_TENANT_LABEL,
|
|
parseEnvAssignments,
|
|
type CellContainerProfile,
|
|
validateFleetImage,
|
|
validateDiskSize,
|
|
validateTenantId,
|
|
} from "./cell-profile.js";
|
|
|
|
const FLEET_BASE_PORT = 19_100;
|
|
const FLEET_CONTAINER_STATE_DIR = "/home/node/.openclaw";
|
|
const FLEET_CONTAINER_AUTH_SECRET_DIR = "/home/node/.config/openclaw";
|
|
const TEST_ENVIRONMENT_FILE = "/tmp/openclaw-fleet-env/cell.env";
|
|
|
|
function makeProfile(overrides: Partial<CellContainerProfile> = {}): CellContainerProfile {
|
|
const tenantId = overrides.tenantId ?? "acme";
|
|
const dataDir = overrides.dataDir ?? "/tmp/openclaw/fleet/cells/acme";
|
|
return {
|
|
tenantId,
|
|
containerName: overrides.containerName ?? cellContainerName(tenantId),
|
|
networkName: overrides.networkName ?? cellNetworkName(tenantId),
|
|
image: DEFAULT_FLEET_IMAGE,
|
|
runtime: "docker",
|
|
hostPort: FLEET_BASE_PORT,
|
|
dataDir,
|
|
authSecretDir: overrides.authSecretDir ?? "/tmp/openclaw/fleet/auth-profile-secrets/acme",
|
|
ownerId: overrides.ownerId ?? cellOwnerId(dataDir),
|
|
attemptId: overrides.attemptId ?? "11111111111111111111111111111111",
|
|
memory: "2g",
|
|
cpus: "2",
|
|
pidsLimit: 512,
|
|
environment: buildCellEnvironment("gateway-token", { TENANT_REGION: "west=1" }),
|
|
selinuxRelabel: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function expectOption(args: string[], flag: string, value: string): void {
|
|
const index = args.indexOf(flag);
|
|
expect(index, `${flag} should be present`).toBeGreaterThanOrEqual(0);
|
|
expect(args[index + 1]).toBe(value);
|
|
}
|
|
|
|
describe("fleet tenant ids", () => {
|
|
it.each(["a", "0", "acme", "acme-2", "a--b", "a".repeat(40)])("accepts %s", (tenantId) => {
|
|
expect(validateTenantId(tenantId)).toBe(tenantId);
|
|
});
|
|
|
|
it.each([
|
|
"",
|
|
".",
|
|
"..",
|
|
"../acme",
|
|
"acme/other",
|
|
"acme\\other",
|
|
"-acme",
|
|
"acme-",
|
|
"Acme",
|
|
"acme_team",
|
|
"acme team",
|
|
"ténant",
|
|
"a".repeat(41),
|
|
])("rejects %j", (tenantId) => {
|
|
expect(() => validateTenantId(tenantId)).toThrow(/tenant id/i);
|
|
});
|
|
|
|
it("derives stable container and data paths", () => {
|
|
expect(cellContainerName("acme-2")).toBe("openclaw-cell-acme-2");
|
|
expect(cellNetworkName("acme-2")).toBe("openclaw-cell-acme-2-net");
|
|
expect(cellDataDir("/srv/openclaw", "acme-2")).toBe(
|
|
path.join("/srv/openclaw", "fleet", "cells", "acme-2"),
|
|
);
|
|
expect(cellAuthSecretDir("/srv/openclaw", "acme-2")).toBe(
|
|
path.join("/srv/openclaw", "fleet", "auth-profile-secrets", "acme-2"),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("fleet port allocation", () => {
|
|
it("starts at the fleet base port and scans gaps", () => {
|
|
expect(allocateHostPort([])).toBe(FLEET_BASE_PORT);
|
|
expect(allocateHostPort([FLEET_BASE_PORT, FLEET_BASE_PORT + 2])).toBe(FLEET_BASE_PORT + 1);
|
|
});
|
|
|
|
it("honors an explicit free TCP port", () => {
|
|
expect(allocateHostPort([FLEET_BASE_PORT], 8080)).toBe(8080);
|
|
});
|
|
|
|
it("rejects explicit collisions and invalid ports", () => {
|
|
expect(() => allocateHostPort([20_000], 20_000)).toThrow(/already allocated/i);
|
|
for (const port of [0, -1, 65_536, 1.5, Number.NaN]) {
|
|
expect(() => allocateHostPort([], port)).toThrow(/1 to 65535/i);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("fleet image references", () => {
|
|
it("normalizes valid references and rejects option-like values", () => {
|
|
expect(validateFleetImage(" ghcr.io/openclaw/openclaw:v1 ")).toBe(
|
|
"ghcr.io/openclaw/openclaw:v1",
|
|
);
|
|
expect(() => validateFleetImage("--help")).toThrow(/must not begin/iu);
|
|
expect(() => validateFleetImage(" ")).toThrow(/must not be empty/iu);
|
|
});
|
|
});
|
|
|
|
describe("fleet disk limits", () => {
|
|
it.each(["10g", "512m", "1.5g", "10GB", "1024", "2tb"])("accepts %s", (value) => {
|
|
expect(validateDiskSize(value)).toBe(value);
|
|
});
|
|
|
|
it.each(["", " ", "0", "0g", "0.0GB", "-1g", "10 g", "g", "10g;x", "--size", "1e9"])(
|
|
"rejects %j",
|
|
(value) => {
|
|
expect(() => validateDiskSize(value)).toThrow(/--disk/iu);
|
|
},
|
|
);
|
|
|
|
it("adds storage-opt and the replay label only when configured", () => {
|
|
const withDisk = buildCellRunArgs(makeProfile({ diskSize: "10g" }), {
|
|
environmentFile: TEST_ENVIRONMENT_FILE,
|
|
});
|
|
const withoutDisk = buildCellRunArgs(makeProfile(), { environmentFile: TEST_ENVIRONMENT_FILE });
|
|
expectOption(withDisk, "--storage-opt", "size=10g");
|
|
expect(withDisk.indexOf("--storage-opt")).toBe(withDisk.indexOf("--cpus") + 2);
|
|
expect(withDisk).toContain("openclaw.fleet.disk-limit=10g");
|
|
expect(withoutDisk).not.toContain("--storage-opt");
|
|
expect(withoutDisk.join(" ")).not.toContain("openclaw.fleet.disk-limit");
|
|
});
|
|
});
|
|
|
|
describe("fleet cell environment", () => {
|
|
it("parses KEY=VAL assignments without truncating values", () => {
|
|
expect(parseEnvAssignments(["REGION=west", "URL=https://example.test/?a=b", "EMPTY="])).toEqual(
|
|
{
|
|
REGION: "west",
|
|
URL: "https://example.test/?a=b",
|
|
EMPTY: "",
|
|
},
|
|
);
|
|
expect(parseEnvAssignments(["REGION=west", "REGION=east"])).toEqual({ REGION: "east" });
|
|
});
|
|
|
|
it.each(["MISSING", "=value", "BAD-KEY=value", "1BAD=value", "HAS SPACE=value"])(
|
|
"rejects malformed assignment %j",
|
|
(assignment) => {
|
|
expect(() => parseEnvAssignments([assignment])).toThrow(/--env/i);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
"HOME",
|
|
"OPENCLAW_HOME",
|
|
"OPENCLAW_STATE_DIR",
|
|
"OPENCLAW_CONFIG_PATH",
|
|
"OPENCLAW_WORKSPACE_DIR",
|
|
"OPENCLAW_GATEWAY_TOKEN",
|
|
])("rejects reserved variable %s", (key) => {
|
|
expect(() => parseEnvAssignments([`${key}=override`])).toThrow(/fleet-managed/i);
|
|
});
|
|
|
|
it("pins the documented container paths and token", () => {
|
|
expect(buildCellEnvironment("secret", { REGION: "west" })).toEqual({
|
|
HOME: "/home/node",
|
|
OPENCLAW_HOME: "/home/node",
|
|
OPENCLAW_STATE_DIR: FLEET_CONTAINER_STATE_DIR,
|
|
OPENCLAW_CONFIG_PATH: `${FLEET_CONTAINER_STATE_DIR}/openclaw.json`,
|
|
OPENCLAW_WORKSPACE_DIR: `${FLEET_CONTAINER_STATE_DIR}/workspace`,
|
|
OPENCLAW_GATEWAY_TOKEN: "secret",
|
|
REGION: "west",
|
|
});
|
|
});
|
|
|
|
it("rejects values that cannot be represented in the protected environment file", () => {
|
|
expect(() => buildCellEnvironment("bad\ntoken", {})).toThrow(/line breaks/iu);
|
|
expect(() => buildCellEnvironment("token", { FEATURE: "line\nbreak" })).toThrow(
|
|
/must be one line/iu,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("fleet container arguments", () => {
|
|
it("builds the complete hardened run profile", () => {
|
|
const profile = makeProfile();
|
|
const args = buildCellRunArgs(profile, { environmentFile: TEST_ENVIRONMENT_FILE });
|
|
|
|
expect(args.slice(0, 2)).toEqual(["run", "-d"]);
|
|
expectOption(args, "--name", "openclaw-cell-acme");
|
|
expectOption(args, "--label", `${FLEET_TENANT_LABEL}=acme`);
|
|
expect(args).toContain(`${FLEET_OWNER_LABEL}=${cellOwnerId(profile.dataDir)}`);
|
|
expect(args).toContain(`${FLEET_ATTEMPT_LABEL}=${profile.attemptId}`);
|
|
expect(args).toContain(`${FLEET_ENV_KEYS_LABEL}=TENANT_REGION`);
|
|
expect(args).toContain("--init");
|
|
expect(args).toContain("--cap-drop=ALL");
|
|
expectOption(args, "--security-opt", "no-new-privileges");
|
|
expectOption(args, "--pids-limit", "512");
|
|
expectOption(args, "--memory", "2g");
|
|
expectOption(args, "--cpus", "2");
|
|
expectOption(args, "--restart", "unless-stopped");
|
|
expectOption(args, "--network", "openclaw-cell-acme-net");
|
|
expectOption(args, "-p", `127.0.0.1:${FLEET_BASE_PORT}:${FLEET_GATEWAY_PORT}`);
|
|
expect(args).toContain(`${profile.dataDir}:${FLEET_CONTAINER_STATE_DIR}`);
|
|
expect(args).toContain(`${profile.authSecretDir}:${FLEET_CONTAINER_AUTH_SECRET_DIR}`);
|
|
expectOption(args, "--env-file", TEST_ENVIRONMENT_FILE);
|
|
expect(args.join(" ")).not.toContain("gateway-token");
|
|
expect(args.join(" ")).not.toContain("west=1");
|
|
expect(args.slice(-8)).toEqual([
|
|
DEFAULT_FLEET_IMAGE,
|
|
"node",
|
|
"dist/index.js",
|
|
"gateway",
|
|
"--bind",
|
|
"lan",
|
|
"--port",
|
|
String(FLEET_GATEWAY_PORT),
|
|
]);
|
|
});
|
|
|
|
it("builds a stopped container with the same profile", () => {
|
|
const args = buildCellCreateArgs(makeProfile(), {
|
|
environmentFile: TEST_ENVIRONMENT_FILE,
|
|
});
|
|
expect(args[0]).toBe("create");
|
|
expect(args).not.toContain("-d");
|
|
expect(args).toContain("--cap-drop=ALL");
|
|
expect(args.slice(-7)).toEqual([
|
|
"node",
|
|
"dist/index.js",
|
|
"gateway",
|
|
"--bind",
|
|
"lan",
|
|
"--port",
|
|
String(FLEET_GATEWAY_PORT),
|
|
]);
|
|
});
|
|
|
|
it("adds rootless keep-id arguments and private SELinux labels for Podman", () => {
|
|
const args = buildCellRunArgs(
|
|
makeProfile({
|
|
runtime: "podman",
|
|
containerUser: { mode: "podman-keep-id", uid: 501, gid: 20 },
|
|
selinuxRelabel: true,
|
|
}),
|
|
{ environmentFile: TEST_ENVIRONMENT_FILE },
|
|
);
|
|
expect(args).toContain("--userns=keep-id");
|
|
expectOption(args, "--user", "501:20");
|
|
expect(args).toContain(`${makeProfile().dataDir}:${FLEET_CONTAINER_STATE_DIR}:Z`);
|
|
expect(args).toContain(`${makeProfile().authSecretDir}:${FLEET_CONTAINER_AUTH_SECRET_DIR}:Z`);
|
|
});
|
|
|
|
it("adds private SELinux labels for Docker mounts", () => {
|
|
const profile = makeProfile({ selinuxRelabel: true });
|
|
const args = buildCellRunArgs(profile, { environmentFile: TEST_ENVIRONMENT_FILE });
|
|
|
|
expect(args).toContain(`${profile.dataDir}:${FLEET_CONTAINER_STATE_DIR}:Z`);
|
|
expect(args).toContain(`${profile.authSecretDir}:${FLEET_CONTAINER_AUTH_SECRET_DIR}:Z`);
|
|
});
|
|
|
|
it("runs rootful Docker with the invoking non-root identity", () => {
|
|
const args = buildCellRunArgs(
|
|
makeProfile({ containerUser: { mode: "numeric", uid: 1001, gid: 1002 } }),
|
|
{ environmentFile: TEST_ENVIRONMENT_FILE },
|
|
);
|
|
|
|
expectOption(args, "--user", "1001:1002");
|
|
expect(args).not.toContain("--userns=keep-id");
|
|
});
|
|
|
|
it("never enables privileged host integration", () => {
|
|
for (const args of [
|
|
buildCellRunArgs(makeProfile(), { environmentFile: TEST_ENVIRONMENT_FILE }),
|
|
buildCellCreateArgs(makeProfile(), { environmentFile: TEST_ENVIRONMENT_FILE }),
|
|
]) {
|
|
const rendered = args.join(" ");
|
|
expect(rendered).not.toContain("docker.sock");
|
|
expect(args).not.toContain("--privileged");
|
|
expect(args).not.toContain("--network=host");
|
|
expectOption(args, "--network", "openclaw-cell-acme-net");
|
|
expect(args[args.indexOf("--network") + 1]).not.toBe("host");
|
|
expect(rendered).not.toContain("--cap-add");
|
|
expect(rendered).not.toContain("0.0.0.0");
|
|
}
|
|
});
|
|
});
|