diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js old mode 100755 new mode 100644 diff --git a/docs/deployment/deploy-to-remote-gpu.md b/docs/deployment/deploy-to-remote-gpu.md index ef001142981..abddf3ece1e 100644 --- a/docs/deployment/deploy-to-remote-gpu.md +++ b/docs/deployment/deploy-to-remote-gpu.md @@ -138,12 +138,21 @@ Changing the proxy after onboarding requires re-running `nemoclaw onboard`. ## GPU Configuration -The deploy script uses the `NEMOCLAW_GPU` environment variable to select the GPU type. +The deploy script uses the `NEMOCLAW_GPU` environment variable to select the Brev instance type and GPU name. The default value is `a2-highgpu-1g:nvidia-tesla-a100:1`. -Set this variable before running `nemoclaw deploy` to use a different GPU configuration: +Legacy `NEMOCLAW_GPU` usage: ```console -$ export NEMOCLAW_GPU="a2-highgpu-1g:nvidia-tesla-a100:2" +$ export NEMOCLAW_GPU="a2-highgpu-1g:nvidia-tesla-a100:1" +$ nemoclaw deploy +``` + +For direct overrides, set `NEMOCLAW_BREV_TYPE` and `NEMOCLAW_BREV_GPU_NAME` instead. +These take precedence over `NEMOCLAW_GPU` when both are set: + +```console +$ export NEMOCLAW_BREV_TYPE="a2-highgpu-1g" +$ export NEMOCLAW_BREV_GPU_NAME="A100" $ nemoclaw deploy ``` diff --git a/src/lib/deploy.ts b/src/lib/deploy.ts index 1640f2b50e6..26a19ae9303 100644 --- a/src/lib/deploy.ts +++ b/src/lib/deploy.ts @@ -105,6 +105,22 @@ export function inferDeployProvider( return null; } +function normalizeBrevGpuName(value: string | undefined): string { + const trimmed = String(value || "").trim(); + if (!trimmed) return ""; + return trimmed.replace(/^nvidia-tesla-/i, "").toUpperCase(); +} + +function resolveBrevCreateConfig(env: NodeJS.ProcessEnv): { type: string; gpuName: string } { + const legacyGpu = String(env.NEMOCLAW_GPU || "").trim(); + const legacyParts = legacyGpu.includes(":") ? legacyGpu.split(":") : [legacyGpu]; + const type = String(env.NEMOCLAW_BREV_TYPE || legacyParts[0] || "a2-highgpu-1g").trim(); + const gpuName = normalizeBrevGpuName( + env.NEMOCLAW_BREV_GPU_NAME || legacyParts[1] || "A100", + ); + return { type, gpuName }; +} + export function buildDeployEnvLines(opts: { env: NodeJS.ProcessEnv; sandboxName: string; @@ -240,7 +256,7 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise const name = validateName(instanceName, "instance name"); const qname = shellQuote(name); - const gpu = env.NEMOCLAW_GPU || "a2-highgpu-1g:nvidia-tesla-a100:1"; + const { type, gpuName } = resolveBrevCreateConfig(env); const brevProvider = String(env.NEMOCLAW_BREV_PROVIDER || "gcp").trim().toLowerCase(); const skipConnect = ["1", "true"].includes( String(env.NEMOCLAW_DEPLOY_NO_CONNECT || "").toLowerCase(), @@ -296,8 +312,10 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise } if (!exists) { - log(` Creating Brev instance '${name}' (${gpu}, provider=${brevProvider})...`); - run(`brev create ${qname} --type ${shellQuote(gpu)} --provider ${shellQuote(brevProvider)}`); + log(` Creating Brev instance '${name}' (${type}, ${gpuName}, provider=${brevProvider})...`); + run( + `brev create ${qname} --type ${shellQuote(type)} --gpu-name ${shellQuote(gpuName)} --provider ${shellQuote(brevProvider)}`, + ); } else { log(` Brev instance '${name}' already exists.`); } diff --git a/test/cli.test.ts b/test/cli.test.ts index 6e28cc4dca9..991c353931d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -33,6 +33,14 @@ function runWithEnv(args, env = {}, timeout = 10000) { } } +function readCommandTokens(markerFile) { + return fs + .readFileSync(markerFile, "utf8") + .trim() + .split(/\s+/) + .filter(Boolean); +} + describe("CLI dispatch", () => { it("help exits 0 and shows sections", () => { const r = run("help"); @@ -253,6 +261,7 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(fs.readFileSync(markerFile, "utf8")).toContain("logs alpha --tail"); expect(fs.readFileSync(markerFile, "utf8")).not.toContain("--follow"); + expect(readCommandTokens(markerFile)).toEqual(["logs", "alpha", "--tail"]); }); it("destroys the gateway runtime when the last sandbox is removed", () => { @@ -738,6 +747,185 @@ describe("CLI dispatch", () => { expect(r.out.includes("Upgrade OpenShell by rerunning `nemoclaw onboard`")).toBeTruthy(); }); + it("deploy uses brev --type and --gpu-name instead of legacy --gpu", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-deploy-brev-")); + const localBin = path.join(home, "bin"); + const markerFile = path.join(home, "brev-args"); + fs.mkdirSync(localBin, { recursive: true }); + + fs.writeFileSync( + path.join(localBin, "brev"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + "printf '%s\\n' \"$*\" >> \"$marker_file\"", + "if [ \"$1\" = \"ls\" ] && [ \"$2\" = \"--json\" ]; then", + " echo '[{\"name\":\"pr-998-test\",\"status\":\"RUNNING\",\"build_status\":\"COMPLETED\",\"shell_status\":\"READY\"}]'", + " exit 0", + "fi", + "if [ \"$1\" = \"ls\" ]; then", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(localBin, "ssh"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "-o" ] || [ "$1" = "-F" ] || [ "$1" = "-q" ] || [ "$1" = "-t" ]; then', + " if printf '%s\\n' \"$*\" | grep -q 'echo \\$HOME'; then", + ` echo ${JSON.stringify(home)}`, + " fi", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + fs.writeFileSync(path.join(localBin, "rsync"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(localBin, "scp"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(localBin, "ssh-keyscan"), "#!/usr/bin/env bash\necho 'test-host-key'\n", { + mode: 0o755, + }); + + const r = runWithEnv("deploy pr-998-test", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NVIDIA_API_KEY: "nvapi-test", + NEMOCLAW_GPU: "a2-highgpu-1g:nvidia-tesla-a100:1", + NEMOCLAW_DEPLOY_NO_CONNECT: "1", + NEMOCLAW_DEPLOY_NO_START_SERVICES: "1", + }, 25000); + + expect(r.code).toBe(0); + const calls = fs.readFileSync(markerFile, "utf8").trim().split("\n"); + expect( + calls.some((call) => + call.includes("create pr-998-test --type a2-highgpu-1g --gpu-name A100 --provider gcp"), + ), + ).toBe(true); + expect(calls.some((call) => call.includes("--gpu "))).toBe(false); + }, 25000); + + it("deploy prefers direct Brev overrides when provided", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-deploy-brev-override-")); + const localBin = path.join(home, "bin"); + const markerFile = path.join(home, "brev-args"); + fs.mkdirSync(localBin, { recursive: true }); + + fs.writeFileSync( + path.join(localBin, "brev"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + "printf '%s\\n' \"$*\" >> \"$marker_file\"", + "if [ \"$1\" = \"ls\" ] && [ \"$2\" = \"--json\" ]; then", + " echo '[{\"name\":\"pr-998-override\",\"status\":\"RUNNING\",\"build_status\":\"COMPLETED\",\"shell_status\":\"READY\"}]'", + " exit 0", + "fi", + "if [ \"$1\" = \"ls\" ]; then", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(localBin, "ssh"), + [ + "#!/usr/bin/env bash", + "if printf '%s\\n' \"$*\" | grep -q 'echo \\$HOME'; then", + ` echo ${JSON.stringify(home)}`, + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + fs.writeFileSync(path.join(localBin, "rsync"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(localBin, "scp"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(localBin, "ssh-keyscan"), "#!/usr/bin/env bash\necho 'test-host-key'\n", { + mode: 0o755, + }); + + const r = runWithEnv("deploy pr-998-override", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NVIDIA_API_KEY: "nvapi-test", + NEMOCLAW_GPU: "a2-highgpu-1g:nvidia-tesla-a100:1", + NEMOCLAW_BREV_TYPE: "l4-standard", + NEMOCLAW_BREV_GPU_NAME: "l4", + NEMOCLAW_DEPLOY_NO_CONNECT: "1", + NEMOCLAW_DEPLOY_NO_START_SERVICES: "1", + }, 25000); + + expect(r.code).toBe(0); + const calls = fs.readFileSync(markerFile, "utf8").trim().split("\n"); + expect( + calls.some((call) => + call.includes("create pr-998-override --type l4-standard --gpu-name L4 --provider gcp"), + ), + ).toBe(true); + }, 25000); + + it("deploy falls back to the default Brev values when no GPU env is set", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-deploy-brev-default-")); + const localBin = path.join(home, "bin"); + const markerFile = path.join(home, "brev-args"); + fs.mkdirSync(localBin, { recursive: true }); + + fs.writeFileSync( + path.join(localBin, "brev"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + "printf '%s\\n' \"$*\" >> \"$marker_file\"", + "if [ \"$1\" = \"ls\" ] && [ \"$2\" = \"--json\" ]; then", + " echo '[{\"name\":\"pr-998-default\",\"status\":\"RUNNING\",\"build_status\":\"COMPLETED\",\"shell_status\":\"READY\"}]'", + " exit 0", + "fi", + "if [ \"$1\" = \"ls\" ]; then", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(localBin, "ssh"), + [ + "#!/usr/bin/env bash", + "if printf '%s\\n' \"$*\" | grep -q 'echo \\$HOME'; then", + ` echo ${JSON.stringify(home)}`, + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + fs.writeFileSync(path.join(localBin, "rsync"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(localBin, "scp"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync(path.join(localBin, "ssh-keyscan"), "#!/usr/bin/env bash\necho 'test-host-key'\n", { + mode: 0o755, + }); + + const r = runWithEnv("deploy pr-998-default", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NVIDIA_API_KEY: "nvapi-test", + NEMOCLAW_DEPLOY_NO_CONNECT: "1", + NEMOCLAW_DEPLOY_NO_START_SERVICES: "1", + }, 25000); + + expect(r.code).toBe(0); + const calls = fs.readFileSync(markerFile, "utf8").trim().split("\n"); + expect( + calls.some((call) => + call.includes("create pr-998-default --type a2-highgpu-1g --gpu-name A100 --provider gcp"), + ), + ).toBe(true); + }, 25000); + it("connect does not pre-start a duplicate port forward", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-forward-")); const localBin = path.join(home, "bin");