Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 22 additions & 0 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { prompt, ensureApiKey, getCredential } = require("./credentials");
const registry = require("./registry");
const nim = require("./nim");
const policies = require("./policies");
const { checkCgroupConfig } = require("./preflight");
const HOST_GATEWAY_URL = "http://host.openshell.internal";

// ── Helpers ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -67,6 +68,27 @@ async function preflight() {
}
console.log(` ✓ openshell CLI: ${runCapture("openshell --version 2>/dev/null || echo unknown", { ignoreError: true })}`);

// cgroup v2 + Docker cgroupns
const cgroup = checkCgroupConfig();
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, run:");
console.error("");
console.error(" nemoclaw setup-spark");
console.error("");
console.error(" This adds \"default-cgroupns-mode\": \"host\" to /etc/docker/daemon.json");
console.error(" (preserving any existing settings) and restarts Docker.");
console.error("");
console.error(` Detail: ${cgroup.reason}`);
process.exit(1);
}
console.log(" ✓ cgroup configuration OK");

// GPU
const gpu = nim.detectGpu();
if (gpu && gpu.type === "nvidia") {
Expand Down
80 changes: 80 additions & 0 deletions bin/lib/preflight.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Preflight checks for NemoClaw onboarding.

const fs = require("fs");
const { runCapture } = require("./runner");

const DEFAULT_DAEMON_JSON = "/etc/docker/daemon.json";

/**
* Detect if the host uses cgroup v2.
*
* Returns true when /sys/fs/cgroup is mounted as cgroup2fs.
* stat -fc is GNU coreutils (Linux). On macOS/BSD this command does not
* exist, so the platform guard ensures we never rely on command failure
* as the non-Linux fallback path.
*
* NOTE: Docker Desktop for Linux runs its own VM and manages its own
* daemon.json. This check targets native Docker Engine installs, which
* is what ships on DGX Spark and WSL2.
*/
function isCgroupV2() {
if (process.platform !== "linux") return false;
const fstype = runCapture("stat -fc %T /sys/fs/cgroup 2>/dev/null", { ignoreError: true });
return fstype === "cgroup2fs";
}

/**
* Read and parse /etc/docker/daemon.json.
*
* Returns the parsed object, or null if the file doesn't exist or isn't
* valid JSON.
*/
function readDaemonJson(daemonPath) {
const p = daemonPath || DEFAULT_DAEMON_JSON;
try {
const raw = fs.readFileSync(p, "utf-8");
return JSON.parse(raw);
} catch {
return null;
}
}

/**
* Check whether Docker is configured for cgroupns=host.
*
* On cgroup v2 systems, OpenShell's gateway starts k3s inside a Docker
* container. k3s needs the host cgroup namespace to manage cgroup
* hierarchies. Without "default-cgroupns-mode": "host" in daemon.json,
* kubelet fails with:
*
* openat2 /sys/fs/cgroup/kubepods/pids.max: no such file or directory
*
* Returns an object:
* { ok: true } -- no issue (cgroup v1, or already configured)
* { ok: false, reason: string } -- needs fix
*/
function checkCgroupConfig(opts) {
const cgroupV2 = opts && typeof opts.cgroupV2 === "boolean" ? opts.cgroupV2 : isCgroupV2();
if (!cgroupV2) {
return { ok: true };
}

const daemonPath = (opts && opts.daemonPath) || DEFAULT_DAEMON_JSON;
const config = readDaemonJson(daemonPath);

if (config && config["default-cgroupns-mode"] === "host") {
return { ok: true };
}

return {
ok: false,
reason: config
? `${daemonPath} exists but "default-cgroupns-mode" is not set to "host"`
: `${daemonPath} does not exist or is not valid JSON`,
};
}

module.exports = { isCgroupV2, readDaemonJson, checkCgroupConfig };
107 changes: 107 additions & 0 deletions test/preflight.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");

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

// Helper: create a temp daemon.json with given content and return its path.
function writeTempDaemon(content) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preflight-"));
const p = path.join(dir, "daemon.json");
fs.writeFileSync(p, content, "utf-8");
return p;
}

describe("isCgroupV2", () => {
it("returns a boolean", () => {
assert.equal(typeof isCgroupV2(), "boolean");
});

it("returns false on non-linux platforms", () => {
// On Linux this still returns a boolean (true or false depending on
// actual cgroup version). On macOS/other it always returns false.
// Either way the function must not throw.
const result = isCgroupV2();
if (process.platform !== "linux") {
assert.equal(result, false);
}
});
});

describe("readDaemonJson", () => {
it("parses valid JSON", () => {
const p = writeTempDaemon('{ "default-cgroupns-mode": "host" }');
const result = readDaemonJson(p);
assert.deepEqual(result, { "default-cgroupns-mode": "host" });
});

it("returns null for invalid JSON", () => {
const p = writeTempDaemon("not json at all");
assert.equal(readDaemonJson(p), null);
});

it("returns null for missing file", () => {
assert.equal(readDaemonJson("/tmp/nonexistent-daemon-" + Date.now() + ".json"), null);
});
});

describe("checkCgroupConfig", () => {
it("runs without arguments (uses live detection)", () => {
const result = checkCgroupConfig();
assert.equal(typeof result.ok, "boolean");
});

it("returns ok when cgroup v1 (skips daemon.json check)", () => {
const result = checkCgroupConfig({ cgroupV2: false });
assert.deepEqual(result, { ok: true });
});

it("returns ok when cgroup v2 and daemon.json has cgroupns=host", () => {
const p = writeTempDaemon('{ "default-cgroupns-mode": "host" }');
const result = checkCgroupConfig({ cgroupV2: true, daemonPath: p });
assert.deepEqual(result, { ok: true });
});

it("fails when cgroup v2 and daemon.json missing", () => {
const p = "/tmp/nonexistent-daemon-" + Date.now() + ".json";
const result = checkCgroupConfig({ cgroupV2: true, daemonPath: p });
assert.equal(result.ok, false);
assert.ok(result.reason.includes("does not exist"));
});

it("fails when cgroup v2 and daemon.json has no cgroupns key", () => {
const p = writeTempDaemon('{ "storage-driver": "overlay2" }');
const result = checkCgroupConfig({ cgroupV2: true, daemonPath: p });
assert.equal(result.ok, false);
assert.ok(result.reason.includes("not set to"));
});

it("fails when cgroup v2 and cgroupns mode is wrong value", () => {
const p = writeTempDaemon('{ "default-cgroupns-mode": "private" }');
const result = checkCgroupConfig({ cgroupV2: true, daemonPath: p });
assert.equal(result.ok, false);
assert.ok(result.reason.includes("not set to"));
});

it("fails when cgroup v2 and daemon.json is invalid JSON", () => {
const p = writeTempDaemon("oops");
const result = checkCgroupConfig({ cgroupV2: true, daemonPath: p });
assert.equal(result.ok, false);
assert.ok(result.reason.includes("not valid JSON"));
});

it("passes with extra keys alongside cgroupns=host", () => {
const p = writeTempDaemon(JSON.stringify({
"storage-driver": "overlay2",
"default-cgroupns-mode": "host",
"log-driver": "json-file",
}));
const result = checkCgroupConfig({ cgroupV2: true, daemonPath: p });
assert.deepEqual(result, { ok: true });
});
});