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
82 changes: 82 additions & 0 deletions src/commands/sandbox/hosts/command-adapters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
addSandboxHostAlias: vi.fn(),
listSandboxHostAliases: vi.fn(),
removeSandboxHostAlias: vi.fn(),
}));

vi.mock("../../../lib/actions/sandbox/host-aliases", () => ({
addSandboxHostAlias: mocks.addSandboxHostAlias,
listSandboxHostAliases: mocks.listSandboxHostAliases,
removeSandboxHostAlias: mocks.removeSandboxHostAlias,
}));

import HostsAddCommand from "./add";
import HostsListCommand from "./list";
import HostsRemoveCommand from "./remove";

const rootDir = process.cwd();

describe("host alias oclif command adapters", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("maps parsed host alias arguments and dry-run flags to actions", async () => {
await HostsAddCommand.run(["alpha", "searxng.local", "192.168.1.105", "--dry-run"], rootDir);
await HostsListCommand.run(["alpha"], rootDir);
await HostsRemoveCommand.run(["alpha", "searxng.local", "--dry-run"], rootDir);

expect(mocks.addSandboxHostAlias).toHaveBeenCalledWith("alpha", {
hostname: "searxng.local",
ip: "192.168.1.105",
dryRun: true,
});
expect(mocks.listSandboxHostAliases).toHaveBeenCalledWith("alpha");
expect(mocks.removeSandboxHostAlias).toHaveBeenCalledWith("alpha", {
hostname: "searxng.local",
dryRun: true,
});
});

it("rejects unknown flags before invoking host alias actions", async () => {
await expect(
HostsAddCommand.run(["alpha", "searxng.local", "192.168.1.105", "--dry-rnu"], rootDir),
).rejects.toThrow("Nonexistent flag: --dry-rnu");
await expect(
HostsRemoveCommand.run(["alpha", "searxng.local", "--force"], rootDir),
).rejects.toThrow("Nonexistent flag: --force");

expect(mocks.addSandboxHostAlias).not.toHaveBeenCalled();
expect(mocks.removeSandboxHostAlias).not.toHaveBeenCalled();
});

it("maps host alias action failures to command output and exit codes", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const previousExitCode = process.exitCode;
process.exitCode = undefined;
try {
mocks.addSandboxHostAlias.mockImplementationOnce(() => {
throw {
name: "HostAliasesCommandError",
lines: ["host alias failed", "try again"],
exitCode: 5,
};
});

await expect(
HostsAddCommand.run(["alpha", "searxng.local", "192.168.1.105"], rootDir),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(5);
expect(error).toHaveBeenCalledWith("host alias failed");
expect(error).toHaveBeenCalledWith("try again");
} finally {
process.exitCode = previousExitCode;
error.mockRestore();
}
});
});
8 changes: 7 additions & 1 deletion src/lib/actions/sandbox/host-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,17 @@ describe("host alias legacy gateway support checks", () => {

describe("legacy gateway Docker probe classification", () => {
it("classifies exact gateway container matches as present", () => {
const result = probeLegacyGatewayContainerWithDeps(() =>
const dockerPs = vi.fn(() =>
dockerPsResult({ stdout: "openshell-cluster-nemoclaw\nother-container\n" }),
);
const result = probeLegacyGatewayContainerWithDeps(dockerPs);

expect(result).toEqual({ state: "present" });
expect(dockerPs).toHaveBeenCalledWith(["ps", "--format", "{{.Names}}"], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
timeout: 5_000,
});
});

it("classifies missing exact gateway container matches as absent", () => {
Expand Down
97 changes: 62 additions & 35 deletions src/lib/actions/sandbox/host-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@

import { isIP } from "node:net";

import {
dockerExecFileSync,
dockerSpawnSync,
type DockerSpawnSyncResult,
} from "../../adapters/docker/exec";
import { dockerExecFileSync, dockerSpawnSync } from "../../adapters/docker/exec";
import { CLI_NAME } from "../../cli/branding";
import type { SandboxEntry } from "../../state/registry";
import * as registry from "../../state/registry";
Expand All @@ -26,6 +22,12 @@ export type LegacyGatewayHostAliasSupportDeps = {
probeLegacyGatewayContainer: () => LegacyGatewayProbe;
};

export type SandboxHostAliasesDeps = Readonly<
LegacyGatewayHostAliasSupportDeps & {
runKubectlInClusterRaw: (args: string[]) => string;
}
>;

// Drivers that run a per-sandbox direct container (openshell-<sandbox>...)
// instead of the legacy k3s gateway. They have no openshell-cluster-nemoclaw
// container and no Kubernetes `Sandbox` custom resource, so the kubectl-based
Expand Down Expand Up @@ -135,23 +137,20 @@ export function assertLegacyGatewayHostAliasSupportWithDeps(
}
}

function assertLegacyGatewayHostAliasSupport(sandboxName: string): void {
assertLegacyGatewayHostAliasSupportWithDeps(sandboxName, {
getSandbox: registry.getSandbox,
probeLegacyGatewayContainer,
});
}

export function probeLegacyGatewayContainerWithDeps(
dockerPs: () => DockerSpawnSyncResult,
dockerPs: typeof dockerSpawnSync,
): LegacyGatewayProbe {
// `docker ps --filter name=...` accepts only substring or anchored regex
// syntax (`name=^/<container>$`) per the Docker CLI reference, and the
// anchor form is fragile across daemon versions. Mirror the unfiltered
// `docker ps --format '{{.Names}}'` pattern used in
// src/lib/sandbox/privileged-exec.ts and do the exact match in code so
// there is no doubt about substring overlap or anchor support.
const result = dockerPs();
const result = dockerPs(["ps", "--format", "{{.Names}}"], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
timeout: HOST_ALIAS_DOCKER_PROBE_TIMEOUT_MS,
});
if (result.error) {
const code = (result.error as NodeJS.ErrnoException).code ?? "";
if (code === "ETIMEDOUT") {
Expand All @@ -175,13 +174,7 @@ export function probeLegacyGatewayContainerWithDeps(
}

function probeLegacyGatewayContainer(): LegacyGatewayProbe {
return probeLegacyGatewayContainerWithDeps(() =>
dockerSpawnSync(["ps", "--format", "{{.Names}}"], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
timeout: HOST_ALIAS_DOCKER_PROBE_TIMEOUT_MS,
}),
);
return probeLegacyGatewayContainerWithDeps(dockerSpawnSync);
}

function validateHostAliasHostname(hostname: string): boolean {
Expand All @@ -202,24 +195,33 @@ function runKubectlInClusterRaw(args: string[]): string {
});
}

function productionHostAliasesDeps(): SandboxHostAliasesDeps {
return {
getSandbox: registry.getSandbox,
probeLegacyGatewayContainer,
runKubectlInClusterRaw,
};
}

function throwKubectlError(action: string, error: unknown): never {
const err = error as { stderr?: unknown; stdout?: unknown; message?: unknown; status?: number };
const detail = String(err?.stderr || err?.stdout || err?.message || "").trim();
hostAliasesFail(` Failed to ${action}.${detail ? ` ${detail}` : ""}`, err?.status || 1);
}

function runKubectlInCluster(args: string[], action: string): string {
function runKubectlInCluster(args: string[], action: string, deps: SandboxHostAliasesDeps): string {
try {
return runKubectlInClusterRaw(args);
return deps.runKubectlInClusterRaw(args);
} catch (error) {
throwKubectlError(action, error);
}
}

function getSandboxResource(sandboxName: string): SandboxResource {
function getSandboxResource(sandboxName: string, deps: SandboxHostAliasesDeps): SandboxResource {
const raw = runKubectlInCluster(
["get", "sandbox", sandboxName, "-o", "json"],
"read host aliases",
deps,
);
try {
return JSON.parse(raw) as SandboxResource;
Expand Down Expand Up @@ -280,8 +282,9 @@ function patchHostAliases(
sandboxName: string,
resource: SandboxResource,
hostAliases: HostAlias[],
deps: SandboxHostAliasesDeps,
): void {
runKubectlInClusterRaw([
deps.runKubectlInClusterRaw([
"patch",
"sandbox",
sandboxName,
Expand All @@ -296,13 +299,14 @@ function patchHostAliasesWithRetry(
buildAliases: BuildHostAliases,
initialResource: SandboxResource,
initialAliases: HostAlias[],
deps: SandboxHostAliasesDeps,
): void {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const resource = attempt === 1 ? initialResource : getSandboxResource(sandboxName);
const resource = attempt === 1 ? initialResource : getSandboxResource(sandboxName, deps);
const aliases = attempt === 1 ? initialAliases : buildAliases(resource);
try {
patchHostAliases(sandboxName, resource, aliases);
patchHostAliases(sandboxName, resource, aliases, deps);
return;
} catch (error) {
if (!isHostAliasPatchConflict(error) || attempt === maxAttempts) {
Expand All @@ -313,8 +317,15 @@ function patchHostAliasesWithRetry(
}

export function listSandboxHostAliases(sandboxName: string): void {
assertLegacyGatewayHostAliasSupport(sandboxName);
const aliases = getHostAliases(getSandboxResource(sandboxName));
listSandboxHostAliasesWithDeps(sandboxName, productionHostAliasesDeps());
}

export function listSandboxHostAliasesWithDeps(
sandboxName: string,
deps: SandboxHostAliasesDeps,
): void {
assertLegacyGatewayHostAliasSupportWithDeps(sandboxName, deps);
const aliases = getHostAliases(getSandboxResource(sandboxName, deps));
if (aliases.length === 0) {
console.log(` No host aliases configured for '${sandboxName}'.`);
return;
Expand Down Expand Up @@ -366,12 +377,20 @@ export function validateSandboxHostAliasRemoveOptions(options: RemoveSandboxHost
export function addSandboxHostAlias(
sandboxName: string,
options: AddSandboxHostAliasOptions = {},
): void {
addSandboxHostAliasWithDeps(sandboxName, options, productionHostAliasesDeps());
}

export function addSandboxHostAliasWithDeps(
sandboxName: string,
options: AddSandboxHostAliasOptions,
deps: SandboxHostAliasesDeps,
): void {
const dryRun = Boolean(options.dryRun);
const { hostname, ip } = validateSandboxHostAliasAddOptions(options);
assertLegacyGatewayHostAliasSupport(sandboxName);
assertLegacyGatewayHostAliasSupportWithDeps(sandboxName, deps);

const resource = getSandboxResource(sandboxName);
const resource = getSandboxResource(sandboxName, deps);
const buildAliases: BuildHostAliases = (currentResource) => {
const aliases = normalizeHostAliases(currentResource);
if (aliases.some((alias) => alias.hostnames.includes(hostname))) {
Expand All @@ -392,19 +411,27 @@ export function addSandboxHostAlias(
console.log(JSON.stringify(buildHostAliasesPatch(resource, aliases), null, 2));
return;
}
patchHostAliasesWithRetry(sandboxName, buildAliases, resource, aliases);
patchHostAliasesWithRetry(sandboxName, buildAliases, resource, aliases, deps);
console.log(` Added host alias ${hostname} -> ${ip}`);
}

export function removeSandboxHostAlias(
sandboxName: string,
options: RemoveSandboxHostAliasOptions = {},
): void {
removeSandboxHostAliasWithDeps(sandboxName, options, productionHostAliasesDeps());
}

export function removeSandboxHostAliasWithDeps(
sandboxName: string,
options: RemoveSandboxHostAliasOptions,
deps: SandboxHostAliasesDeps,
): void {
const dryRun = Boolean(options.dryRun);
const { hostname } = validateSandboxHostAliasRemoveOptions(options);
assertLegacyGatewayHostAliasSupport(sandboxName);
assertLegacyGatewayHostAliasSupportWithDeps(sandboxName, deps);

const resource = getSandboxResource(sandboxName);
const resource = getSandboxResource(sandboxName, deps);
const buildAliases: BuildHostAliases = (currentResource) => {
const original = normalizeHostAliases(currentResource);
const aliases = original
Expand All @@ -428,6 +455,6 @@ export function removeSandboxHostAlias(
console.log(JSON.stringify(buildHostAliasesPatch(resource, aliases), null, 2));
return;
}
patchHostAliasesWithRetry(sandboxName, buildAliases, resource, aliases);
patchHostAliasesWithRetry(sandboxName, buildAliases, resource, aliases, deps);
console.log(` Removed host alias ${hostname}`);
}
23 changes: 10 additions & 13 deletions src/lib/onboard/gateway-start-failure-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,22 @@
// test/onboard-gateway-docker-unreachable.test.ts.

import { describe, expect, it, vi } from "vitest";
// `handleFinalGatewayStartFailure` is exposed via `module.exports = {...}` at
// the bottom of onboard.ts (it is not a TypeScript `export`). The shared source
// hook preserves those CommonJS semantics without requiring a CLI build.
import * as onboardExports from "../onboard";
import { classifyGatewayStartFailure } from "../validation";
import {
createFinalGatewayStartFailureHandler,
printDockerDaemonRecovery,
reportLegacyGatewayStartResultFailure,
} from "./gateway-start-failure";

const handleFinalGatewayStartFailure: (opts: {
retries: number;
dockerUnreachable?: boolean;
collectDiagnostics?: () => string;
cleanupGateway?: () => void;
exitProcess?: (code: number) => never;
printError?: (message?: string) => void;
}) => never = (onboardExports as unknown as Record<string, unknown>)
.handleFinalGatewayStartFailure as never;
// The production binding itself remains covered by
// test/gateway-final-failure-cleanup.test.ts. These helper and composition
// checks only need the production factory, and should not load onboard.ts's
// full dependency graph for every source-test worker.
const handleFinalGatewayStartFailure = createFinalGatewayStartFailureHandler({
getGatewayName: () => "nemoclaw",
collectDiagnostics: () => "",
cleanupGateway: () => undefined,
});

// Real signatures the legacy script's fake openshell binary emitted from
// `gateway start` to simulate Colima-stopped (macOS) and dockerd-stopped
Expand Down
Loading