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
38 changes: 38 additions & 0 deletions src/lib/actions/sandbox/host-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ import { isIP } from "node:net";

import { dockerExecFileSync } from "../../adapters/docker";
import { CLI_NAME } from "../../cli/branding";
import * as registry from "../../state/registry";

const K3S_CONTAINER = "openshell-cluster-nemoclaw";
const HOST_ALIAS_KUBECTL_TIMEOUT_MS = 10_000;

// 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
// host-alias backend below cannot target them. See src/lib/sandbox/privileged-exec.ts
// for the direct-container resolution these drivers use elsewhere.
const DIRECT_CONTAINER_DRIVERS = new Set(["docker", "vm"]);

type HostAlias = {
ip: string;
hostnames: string[];
Expand Down Expand Up @@ -57,6 +65,33 @@ function hostAliasesFail(lines: string | readonly string[], exitCode = 1): never
throw new HostAliasesCommandError(lines, exitCode);
}

function normalizeDriver(driver: unknown): string | null {
return typeof driver === "string" && driver.trim()
? driver.trim().toLowerCase()
: null;
}

// Host aliases are persisted on the legacy Kubernetes gateway `Sandbox`
// custom resource and applied by `docker exec openshell-cluster-nemoclaw
// kubectl ...`. The docker and vm drivers run per-sandbox direct containers
// with no gateway cluster container and no `Sandbox` CR, so this k3s
// control-plane path cannot work for them. Fail fast with an actionable
// message instead of targeting a container that does not exist (#4516) — and
// without pretending a one-time /etc/hosts edit inside the direct container
// would survive a sandbox restart or rebuild.
function assertLegacyGatewayHostAliasSupport(sandboxName: string): void {
const driver = normalizeDriver(registry.getSandbox(sandboxName)?.openshellDriver);
if (driver && DIRECT_CONTAINER_DRIVERS.has(driver)) {
hostAliasesFail([
` Host aliases are not supported on the '${driver}' driver sandbox '${sandboxName}'.`,
" This command edits aliases on the legacy Kubernetes gateway sandbox resource,",
` which the ${driver} driver does not run (there is no openshell-cluster-nemoclaw container).`,
" OpenShell does not yet expose a persistent host-alias API for this driver, and a",
" one-time /etc/hosts edit would not survive a sandbox restart or rebuild.",
]);
}
}

function validateHostAliasHostname(hostname: string): boolean {
if (!hostname || hostname.length > 253) return false;
return hostname.split(".").every((label) => {
Expand Down Expand Up @@ -186,6 +221,7 @@ function patchHostAliasesWithRetry(
}

export function listSandboxHostAliases(sandboxName: string): void {
assertLegacyGatewayHostAliasSupport(sandboxName);
const aliases = getHostAliases(getSandboxResource(sandboxName));
if (aliases.length === 0) {
console.log(` No host aliases configured for '${sandboxName}'.`);
Expand All @@ -207,6 +243,7 @@ export function addSandboxHostAlias(
sandboxName: string,
options: AddSandboxHostAliasOptions = {},
): void {
assertLegacyGatewayHostAliasSupport(sandboxName);
const dryRun = Boolean(options.dryRun);
const { hostname: rawHostname, ip } = options;
if (!rawHostname || !ip) {
Expand Down Expand Up @@ -249,6 +286,7 @@ export function removeSandboxHostAlias(
sandboxName: string,
options: RemoveSandboxHostAliasOptions = {},
): void {
assertLegacyGatewayHostAliasSupport(sandboxName);
const dryRun = Boolean(options.dryRun);
const { hostname: rawHostname } = options;
if (!rawHostname) {
Expand Down
42 changes: 42 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ type SandboxEntry = {
gpuEnabled: boolean;
policies: string[];
agent?: string;
openshellDriver?: string | null;
agentVersion?: string | null;
};

Expand Down Expand Up @@ -2582,6 +2583,47 @@ describe("CLI dispatch", () => {
});
});

for (const driver of ["docker", "vm"] as const) {
it(`gates host alias commands on the ${driver} driver without targeting the legacy gateway container`, testTimeoutOptions(30_000), () => {
const home = fs.mkdtempSync(
path.join(os.tmpdir(), `nemoclaw-cli-hosts-${driver}-`),
);
const localBin = path.join(home, "bin");
const dockerLog = path.join(home, "docker.log");
fs.mkdirSync(localBin, { recursive: true });
// Record any docker invocation so we can prove the gate fires before
// the legacy `docker exec openshell-cluster-nemoclaw kubectl` path.
writeHostAliasDockerStub(localBin, dockerLog, [
{ ip: "10.0.0.5", hostnames: ["old.local"] },
]);
writeSandboxRegistry(home, "alpha", { openshellDriver: driver });

const env = { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` };
const list = runWithEnv("alpha hosts-list", env);
const add = runWithEnv("alpha hosts-add searxng.local 192.168.1.105", env);
const remove = runWithEnv("alpha hosts-remove searxng.local", env);

for (const result of [list, add, remove]) {
expect(result.code).toBe(1);
expect(result.out).toContain(
`Host aliases are not supported on the '${driver}' driver sandbox 'alpha'.`,
);
}

// Even the dry-run preview must not reach the legacy resource read.
const dryRun = runWithEnv(
"alpha hosts-add searxng.local 192.168.1.105 --dry-run",
env,
);
expect(dryRun.code).toBe(1);
expect(dryRun.out).not.toContain("/spec/podTemplate/spec/hostAliases");

// The gate runs before any docker exec, so the legacy gateway container
// is never targeted.
expect(fs.existsSync(dockerLog)).toBe(false);
});
}

it("supports oclif-native sandbox command forms", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-native-sandbox-"));
writeSandboxRegistry(home);
Expand Down
Loading