Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const { prompt, ensureApiKey, getCredential } = require("./credentials");
const registry = require("./registry");
const nim = require("./nim");
const policies = require("./policies");
const { checkPortAvailable } = require("./preflight");
const { checkPortAvailable, checkCgroupConfig } = require("./preflight");
const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1";
const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY;
const DIM = USE_COLOR ? "\x1b[2m" : "";
Expand Down Expand Up @@ -343,6 +343,24 @@ async function preflight() {
console.log(` ✓ Container runtime: ${runtime}`);
}

// Cgroup v2 — must be configured for cgroupns=host
const cgroup = checkCgroupConfig({ runtime });
if (!cgroup.ok) {
console.error("");
console.error(" !! cgroup v2 detected but Docker is not configured for cgroupns=host.");
console.error(" OpenShell's gateway runs k3s inside Docker, which will fail with:");
console.error("");
console.error(" openat2 /sys/fs/cgroup/kubepods/pids.max: no such file or directory");
console.error("");
console.error(" To fix:");
console.error("");
console.error(` ${cgroup.fix}`);
console.error("");
console.error(` Detail: ${cgroup.reason}`);
process.exit(1);
}
console.log(" ✓ cgroup configuration OK");

// OpenShell CLI
if (!isOpenshellInstalled()) {
console.log(" openshell CLI not found. Installing...");
Expand Down
130 changes: 129 additions & 1 deletion bin/lib/preflight.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,132 @@ async function checkPortAvailable(port, opts) {
});
}

module.exports = { checkPortAvailable };
/**
* Detect whether the Docker host uses cgroup v2.
*
* On Linux: runs `stat -fc %T /sys/fs/cgroup` (returns "cgroup2fs" for v2).
* On macOS: Docker runs in a VM, so check `docker info` for "Cgroup Version: 2".
*
* opts.statOutput — inject stat output for testing (Linux path)
* opts.dockerInfoOutput — inject docker info output for testing (macOS path)
* opts.platform — override process.platform for testing
*/
function isCgroupV2(opts) {
const o = opts || {};
const platform = o.platform || process.platform;

if (platform === "linux") {
let statOut;
if (typeof o.statOutput === "string") {
statOut = o.statOutput;
} else {
statOut = runCapture("stat -fc %T /sys/fs/cgroup 2>/dev/null", { ignoreError: true });
}
return typeof statOut === "string" && statOut.trim() === "cgroup2fs";
}

// macOS / other — check Docker VM's cgroup version via docker info
let dockerInfo;
if (typeof o.dockerInfoOutput === "string") {
dockerInfo = o.dockerInfoOutput;
} else {
dockerInfo = runCapture("docker info 2>/dev/null", { ignoreError: true });
}
if (typeof dockerInfo === "string") {
const match = dockerInfo.match(/Cgroup Version:\s*(\d+)/i);
return match && match[1] === "2";
}
return false;
}

/**
* Read Docker daemon configuration.
*
* On Linux: /etc/docker/daemon.json
* On macOS Docker Desktop: ~/.docker/daemon.json
*
* opts.daemonJsonContent — inject file content for testing
* opts.platform — override process.platform
*/
function readDaemonJson(opts) {
const o = opts || {};
const platform = o.platform || process.platform;

if (typeof o.daemonJsonContent === "string") {
try { return JSON.parse(o.daemonJsonContent); } catch { return null; }
}

const fs = require("fs");
const paths = [];
if (platform === "linux") {
paths.push("/etc/docker/daemon.json");
} else if (platform === "darwin") {
const home = process.env.HOME || "/tmp";
paths.push(require("path").join(home, ".docker", "daemon.json"));
}

for (const p of paths) {
try {
const content = fs.readFileSync(p, "utf-8");
return JSON.parse(content);
} catch {
continue;
}
}
return null;
}

/**
* Check whether Docker is configured for cgroup v2 compatibility.
*
* Returns { ok: true } when cgroup v1 or properly configured.
* Returns { ok: false, runtime, reason, fix } with platform-specific guidance.
*
* opts.platform — override process.platform
* opts.runtime — container runtime name (from inferContainerRuntime)
* opts.cgroupV2 — override isCgroupV2 result for testing
* opts.daemonConfig — override readDaemonJson result for testing
*/
function checkCgroupConfig(opts) {
const o = opts || {};
const platform = o.platform || process.platform;
const runtime = o.runtime || "unknown";

const isV2 = typeof o.cgroupV2 === "boolean" ? o.cgroupV2 : isCgroupV2({ platform, statOutput: o.statOutput, dockerInfoOutput: o.dockerInfoOutput });
if (!isV2) {
return { ok: true };
}

const config = o.daemonConfig !== undefined ? o.daemonConfig : readDaemonJson({ platform, daemonJsonContent: o.daemonJsonContent });
const cgroupMode = config && config["default-cgroupns-mode"];
if (cgroupMode === "host") {
return { ok: true };
}

// cgroup v2 detected but not configured — provide platform-specific fix
let fix, reason;

if (runtime === "colima") {
fix = "colima stop && colima start --cgroupns-mode host";
reason = "Colima is running with cgroup v2 but cgroupns-mode is not set to host";
} else if (runtime === "docker-desktop" || platform === "darwin") {
fix = 'Open Docker Desktop → Settings → Docker Engine → add "default-cgroupns-mode": "host" → Apply & restart';
reason = platform === "darwin"
? "Docker Desktop VM uses cgroup v2 but daemon.json does not set default-cgroupns-mode to host"
: "Docker Desktop uses cgroup v2 but daemon.json does not set default-cgroupns-mode to host";
} else {
// Linux with plain Docker
fix = "sudo nemoclaw setup-spark";
if (config === null) {
reason = "/etc/docker/daemon.json does not exist";
} else if (!config["default-cgroupns-mode"]) {
reason = '/etc/docker/daemon.json exists but "default-cgroupns-mode" is not set to "host"';
} else {
reason = `/etc/docker/daemon.json has "default-cgroupns-mode": "${config["default-cgroupns-mode"]}" (expected "host")`;
}
}

return { ok: false, runtime, reason, fix };
}

module.exports = { checkPortAvailable, isCgroupV2, readDaemonJson, checkCgroupConfig };
126 changes: 125 additions & 1 deletion test/preflight.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const { describe, it } = require("node:test");
const assert = require("node:assert/strict");

const net = require("net");
const { checkPortAvailable } = require("../bin/lib/preflight");
const { checkPortAvailable, isCgroupV2, readDaemonJson, checkCgroupConfig } = require("../bin/lib/preflight");

describe("checkPortAvailable", () => {
it("falls through to net probe when lsof output is empty", async () => {
Expand Down Expand Up @@ -123,3 +123,127 @@ describe("checkPortAvailable", () => {
assert.equal(result.ok, true);
});
});

describe("isCgroupV2", () => {
it("returns true when stat output is cgroup2fs (Linux)", () => {
const result = isCgroupV2({ platform: "linux", statOutput: "cgroup2fs\n" });
assert.equal(result, true);
});

it("returns false when stat output is tmpfs (Linux, cgroup v1)", () => {
const result = isCgroupV2({ platform: "linux", statOutput: "tmpfs\n" });
assert.equal(result, false);
});

it("returns false when stat fails (empty output)", () => {
const result = isCgroupV2({ platform: "linux", statOutput: "" });
assert.equal(result, false);
});

it("returns true when docker info shows Cgroup Version: 2 (macOS)", () => {
const dockerInfo = [
"Client:",
" Version: 24.0.7",
"Server:",
" Cgroup Driver: systemd",
" Cgroup Version: 2",
].join("\n");
const result = isCgroupV2({ platform: "darwin", dockerInfoOutput: dockerInfo });
assert.equal(result, true);
});

it("returns false when docker info shows Cgroup Version: 1 (macOS)", () => {
const dockerInfo = [
"Client:",
" Version: 24.0.7",
"Server:",
" Cgroup Driver: cgroupfs",
" Cgroup Version: 1",
].join("\n");
const result = isCgroupV2({ platform: "darwin", dockerInfoOutput: dockerInfo });
assert.equal(result, false);
});
});

describe("readDaemonJson", () => {
it("parses valid JSON content", () => {
const content = JSON.stringify({ "default-cgroupns-mode": "host", "storage-driver": "overlay2" });
const result = readDaemonJson({ daemonJsonContent: content });
assert.deepEqual(result, { "default-cgroupns-mode": "host", "storage-driver": "overlay2" });
});

it("returns null for invalid JSON", () => {
const result = readDaemonJson({ daemonJsonContent: "not json {{{" });
assert.equal(result, null);
});

it("returns null when no content provided and no file exists", () => {
// Use a platform where the daemon.json path won't exist
const result = readDaemonJson({ platform: "linux" });
// On CI/dev machines /etc/docker/daemon.json may or may not exist,
// but we can at least verify the return type is object or null.
assert.ok(result === null || typeof result === "object");
});
});

describe("checkCgroupConfig", () => {
it("returns ok when cgroup v1 (not v2)", () => {
const result = checkCgroupConfig({ cgroupV2: false });
assert.deepEqual(result, { ok: true });
});

it("returns ok when cgroup v2 + cgroupns=host configured", () => {
const result = checkCgroupConfig({
cgroupV2: true,
daemonConfig: { "default-cgroupns-mode": "host" },
});
assert.deepEqual(result, { ok: true });
});

it("returns not ok on Linux with no daemon.json, fix suggests setup-spark", () => {
const result = checkCgroupConfig({
platform: "linux",
runtime: "docker",
cgroupV2: true,
daemonConfig: null,
});
assert.equal(result.ok, false);
assert.ok(result.fix.includes("setup-spark"));
assert.ok(result.reason.includes("does not exist"));
});

it("returns not ok on Docker Desktop, fix suggests Docker Desktop settings", () => {
const result = checkCgroupConfig({
platform: "darwin",
runtime: "docker-desktop",
cgroupV2: true,
daemonConfig: null,
});
assert.equal(result.ok, false);
assert.ok(result.fix.includes("Docker Desktop"));
assert.ok(result.reason.includes("daemon.json"));
});

it("returns not ok on Colima, fix suggests colima restart with flag", () => {
const result = checkCgroupConfig({
platform: "darwin",
runtime: "colima",
cgroupV2: true,
daemonConfig: null,
});
assert.equal(result.ok, false);
assert.ok(result.fix.includes("colima stop"));
assert.ok(result.fix.includes("--cgroupns-mode host"));
assert.ok(result.reason.includes("Colima"));
});

it("returns ok when cgroup v2 + Colima + daemonConfig has host mode", () => {
const result = checkCgroupConfig({
platform: "darwin",
runtime: "colima",
cgroupV2: true,
daemonConfig: { "default-cgroupns-mode": "host" },
});
assert.deepEqual(result, { ok: true });
});
});
Loading