Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
},
"allowedCycles": [],
"maxRootFiles": {
"src/lib/onboard": 309,
"src/lib/onboard": 308,
"src/lib/actions": 19,
"src/lib/actions/sandbox": 183,
"src/lib/state": 38,
Expand Down
15 changes: 13 additions & 2 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3468,6 +3468,7 @@ repair_installer_nvidia_cdi_spec() {
run_installer_host_preflight() {
local preflight_module="${NEMOCLAW_SOURCE_ROOT}/dist/lib/onboard/preflight.js"
local gateway_management_module="${NEMOCLAW_SOURCE_ROOT}/dist/lib/onboard/gateway-management.js"
local portable_profile_module="${NEMOCLAW_SOURCE_ROOT}/dist/lib/onboard/experimental/portable-profile.js"
local host_readiness_module="${NEMOCLAW_SOURCE_ROOT}/dist/lib/readiness/host.js"
local onboard_admission_module="${NEMOCLAW_SOURCE_ROOT}/dist/lib/readiness/onboard-admission.js"
if ! command_exists node \
Expand All @@ -3488,6 +3489,16 @@ run_installer_host_preflight() {
const hostReadinessPath = process.argv[2];
const onboardAdmissionPath = process.argv[3];
const gatewayManagementPath = process.argv[4];
const portableProfilePath = process.argv[5];
let explicitlySelectedPortableProfile = false;
try {
const portableProfile = require(portableProfilePath);
if (typeof portableProfile.isPortableExperimentalProfile === "function") {
explicitlySelectedPortableProfile = Boolean(
portableProfile.isPortableExperimentalProfile()
);
}
} catch {}
try {
const { assessHost, planHostAdvisories } = require(preflightPath);
const { createHostReadinessReport } = require(hostReadinessPath);
Expand All @@ -3512,7 +3523,7 @@ run_installer_host_preflight() {
);
const admission = evaluateOnboardReadinessAdmission(readiness, {
explicitlyOptedOutGpuPassthrough: false,
allowUnsupportedRuntime: false,
allowUnsupportedRuntime: explicitlySelectedPortableProfile,
// The installer starts a NemoClaw-managed onboarding flow. Let the
// authoritative onboarding gate apply supported storage remediation,
// but only when the gateway declaration confirms NemoClaw ownership.
Expand Down Expand Up @@ -3584,7 +3595,7 @@ run_installer_host_preflight() {
} catch {
process.exit(0);
}
' "$preflight_module" "$host_readiness_module" "$onboard_admission_module" "$gateway_management_module"
' "$preflight_module" "$host_readiness_module" "$onboard_admission_module" "$gateway_management_module" "$portable_profile_module"
)"; then
status=0
else
Expand Down
10 changes: 9 additions & 1 deletion test/helpers/installer-readiness-stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ export function writeFailedOnboardSession(home: string): void {

export function writeInstallerReadinessModuleStubs(readinessDir: string): void {
const onboardDir = path.join(path.dirname(readinessDir), "onboard");
const experimentalDir = path.join(onboardDir, "experimental");
fs.mkdirSync(readinessDir, { recursive: true });
fs.mkdirSync(onboardDir, { recursive: true });
fs.mkdirSync(experimentalDir, { recursive: true });
fs.writeFileSync(
`${readinessDir}/host.js`,
`exports.createHostReadinessReport = (_options, collection) => ({ host: collection.assess() });\n`,
Expand All @@ -51,7 +53,9 @@ export function writeInstallerReadinessModuleStubs(readinessDir: string): void {
`${readinessDir}/onboard-admission.js`,
`exports.evaluateOnboardReadinessAdmission = (report, options) => {
const host = report.host;
const unsupportedRuntime = host.runtime === "podman" || host.isUnsupportedRuntime === true;
const unsupportedRuntime =
(host.runtime === "podman" || host.isUnsupportedRuntime === true) &&
!options.allowUnsupportedRuntime;
const cdiBlocks = host.cdiNvidiaGpuSpecNeedsRepair && !(host.isWsl && host.runtime === "docker-desktop");
const storageRemediationAvailable =
host.platform === "linux" &&
Expand Down Expand Up @@ -91,6 +95,10 @@ export function writeInstallerReadinessModuleStubs(readinessDir: string): void {
};
`,
);
fs.writeFileSync(
`${experimentalDir}/portable-profile.js`,
`exports.isPortableExperimentalProfile = (env = process.env) => env.NEMOCLAW_EXPERIMENTAL_PROFILE === "portable";\n`,
);
}

export function runStorageRemediationInstallerPreflight({
Expand Down
32 changes: 32 additions & 0 deletions test/install-portable-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, expect, it } from "vitest";
Expand Down Expand Up @@ -46,4 +48,34 @@ describe("installer portable profile runtime override", () => {
expect(result.stdout).toBe("DOCKER_HOST=unix:///preexisting.sock\n");
expect(result.stderr).toBe("");
});

it("rejects an unknown experimental profile before install effects (#9007)", () => {
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-invalid-profile-"));
const marker = path.join(fixture, "existing-state");
fs.writeFileSync(marker, "unchanged\n");
const stateBefore = fs.readdirSync(fixture);

const result = spawnSync(
"bash",
[INSTALLER_PAYLOAD, "--experimental-profile", "not-portable"],
{
cwd: fixture,
encoding: "utf-8",
env: {
...process.env,
HOME: fixture,
NEMOCLAW_EXPERIMENTAL_PROFILE: "",
TMPDIR: fixture,
XDG_CONFIG_HOME: path.join(fixture, "config"),
},
},
);

expect(result.status).toBe(1);
expect(`${result.stdout}${result.stderr}`).toContain(
"Unknown experimental profile: not-portable (expected: portable).",
);
expect(fs.readdirSync(fixture)).toEqual(stateBefore);
expect(fs.readFileSync(marker, "utf-8")).toBe("unchanged\n");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
161 changes: 141 additions & 20 deletions test/package-contract/installer-host-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,25 @@ function runInstallerHostAdmissionTest(
runtime: string;
hasNestedOverlayConflict?: boolean;
isUnsupportedRuntime?: boolean;
additionalFindingIds?: string[];
unknownCapabilityIds?: string[];
},
forcedRejection?: { findingIds: string[]; capabilityIds: string[] },
options: {
experimentalProfile?: string;
gatewayManagementMode?: string;
portableProfileArtifact?: "present" | "missing";
} = {},
) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-host-admission-"));
const fakeBin = path.join(tmp, "bin");
const sourceRoot = path.join(tmp, "source");
const onboardDir = path.join(sourceRoot, "dist", "lib", "onboard");
const experimentalDir = path.join(onboardDir, "experimental");
const readinessDir = path.join(sourceRoot, "dist", "lib", "readiness");
fs.mkdirSync(fakeBin);
fs.mkdirSync(onboardDir, { recursive: true });
fs.mkdirSync(experimentalDir, { recursive: true });
fs.mkdirSync(readinessDir, { recursive: true });

fs.writeFileSync(
Expand All @@ -59,8 +68,25 @@ exports.planHostAdvisories = () => [];
);
fs.writeFileSync(
path.join(onboardDir, "gateway-management.js"),
`exports.loadGatewayManagementDeclaration = () => ({ ok: true, declaration: null });\n`,
`const mode = process.env.TEST_GATEWAY_MANAGEMENT_MODE;
exports.loadGatewayManagementDeclaration = () => ({
ok: true,
declaration: mode ? { mode } : null,
});
`,
);
const portableProfileArtifacts =
options.portableProfileArtifact === "missing"
? []
: [
[
path.join(experimentalDir, "portable-profile.js"),
`exports.isPortableExperimentalProfile = (env = process.env) => env.NEMOCLAW_EXPERIMENTAL_PROFILE === "portable";\n`,
] as const,
];
for (const [artifactPath, contents] of portableProfileArtifacts) {
fs.writeFileSync(artifactPath, contents);
}
fs.writeFileSync(
path.join(readinessDir, "host.js"),
`exports.createHostReadinessReport = (_options, collection) => {
Expand All @@ -80,7 +106,10 @@ exports.planHostAdvisories = () => [];
summary: "The detected container runtime is unsupported.",
});
}
return { findings, host };
for (const id of host.additionalFindingIds || []) {
findings.push({ id, severity: "blocking", summary: "Blocking finding: " + id });
}
return { findings, capabilityIds: host.unknownCapabilityIds || [], host };
};
`,
);
Expand All @@ -92,22 +121,55 @@ exports.evaluateOnboardReadinessAdmission = (report, options) => {
return { admitted: false, reasonIds: [], ...forcedRejection, waivedFindingIds: [] };
}
const findingIds = report.findings
.filter((finding) =>
finding.id !== "host.docker.storage_incompatible" || !options.allowStorageRemediation
)
.filter((finding) => {
if (
finding.id === "host.docker.runtime_unsupported" &&
options.allowUnsupportedRuntime
) return false;
if (
finding.id === "host.docker.storage_incompatible" &&
options.allowStorageRemediation
) return false;
if (
options.allowPortableHostPreparation &&
(finding.id === "host.docker.daemon_unreachable" ||
finding.id === "host.docker.storage_incompatible")
) return false;
return true;
})
.map((finding) => finding.id);
return findingIds.length === 0
? { admitted: true, waivedFindingIds: ["host.docker.storage_incompatible"] }
: { admitted: false, reasonIds: [], findingIds, capabilityIds: [], waivedFindingIds: [] };
const capabilityIds = report.capabilityIds.filter(
(id) =>
!options.allowPortableHostPreparation ||
(id !== "host.docker.daemon_reachable" &&
id !== "host.docker.runtime_supported" &&
id !== "host.docker.storage_compatible")
);
return findingIds.length === 0 && capabilityIds.length === 0
? { admitted: true, waivedFindingIds: [] }
: { admitted: false, reasonIds: [], findingIds, capabilityIds, waivedFindingIds: [] };
};
`,
);
fs.writeFileSync(
path.join(onboardDir, "gateway-management.js"),
`exports.loadGatewayManagementDeclaration = () => ({ ok: true, declaration: null });\n`,
);
writeNodeStub(fakeBin);

const {
NEMOCLAW_EXPERIMENTAL_PROFILE: _experimentalProfile,
TEST_GATEWAY_MANAGEMENT_MODE: _gatewayManagementMode,
...inheritedEnv
} = process.env;
const childEnv: NodeJS.ProcessEnv = {
...inheritedEnv,
HOME: tmp,
INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD,
PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`,
SOURCE_ROOT: sourceRoot,
...(options.experimentalProfile
? { NEMOCLAW_EXPERIMENTAL_PROFILE: options.experimentalProfile }
: {}),
TEST_GATEWAY_MANAGEMENT_MODE: options.gatewayManagementMode ?? "",
};

const result = spawnSync(
"bash",
[
Expand All @@ -121,13 +183,7 @@ run_installer_host_preflight
{
cwd: tmp,
encoding: "utf-8",
env: {
...process.env,
HOME: tmp,
INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD,
PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`,
SOURCE_ROOT: sourceRoot,
},
env: childEnv,
},
);

Expand All @@ -145,7 +201,21 @@ describe("installer host preflight package contract", () => {
expect(output).not.toMatch(/Host preflight found issues/);
});

it("prints the blocking readiness finding when no advisory action exists", () => {
it("admits an unsupported runtime for the explicit portable profile (#9007)", () => {
const { output, result } = runInstallerHostAdmissionTest(
{
runtime: "podman",
isUnsupportedRuntime: true,
},
undefined,
{ experimentalProfile: "portable" },
);

expect(result.status, output).toBe(0);
expect(output).not.toMatch(/Host preflight found issues/);
});

it("rejects the same unsupported runtime without the portable profile (#9007)", () => {
const { output, result } = runInstallerHostAdmissionTest({
runtime: "podman",
isUnsupportedRuntime: true,
Expand All @@ -156,6 +226,57 @@ describe("installer host preflight package contract", () => {
expect(output).toMatch(/The detected container runtime is unsupported\./);
});

it("keeps an unsupported runtime blocked without the portable classifier artifact (#9007)", () => {
const { output, result } = runInstallerHostAdmissionTest(
{
runtime: "podman",
isUnsupportedRuntime: true,
},
undefined,
{ experimentalProfile: "portable", portableProfileArtifact: "missing" },
);

expect(result.status).toBe(1);
expect(output).toMatch(/Host preflight found issues/);
expect(output).toMatch(/The detected container runtime is unsupported\./);
});

it.each([
["daemon reachability", "host.docker.daemon_unreachable", undefined],
["storage compatibility", "host.docker.storage_incompatible", "externally-supervised"],
["GPU prerequisites", "host.gpu.container_toolkit_missing", undefined],
["platform qualification", "host.platform.unsupported", undefined],
["an injected finding", "host.test.blocked", undefined],
])("rejects %s blockers for the explicit portable profile (#9007)", (_, findingId, mode) => {
const { output, result } = runInstallerHostAdmissionTest(
{
runtime: "podman",
isUnsupportedRuntime: true,
additionalFindingIds: [findingId],
},
undefined,
{ experimentalProfile: "portable", gatewayManagementMode: mode },
);

expect(result.status, output).toBe(1);
expect(output).toContain(findingId);
});

it("rejects an unknown required capability for the explicit portable profile (#9007)", () => {
const { output, result } = runInstallerHostAdmissionTest(
{
runtime: "podman",
isUnsupportedRuntime: true,
unknownCapabilityIds: ["host.docker.runtime_supported"],
},
undefined,
{ experimentalProfile: "portable" },
);

expect(result.status, output).toBe(1);
expect(output).toContain("host.docker.runtime_supported");
});

it("prints only stable unknown finding and required-capability diagnostics", () => {
const oversizedFindingId = `host.${"f".repeat(124)}`;
const oversizedCapabilityId = `host.${"c".repeat(124)}`;
Expand Down
Loading