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
45 changes: 40 additions & 5 deletions src/lib/actions/maintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,10 @@ vi.mock("../credentials/store", () => ({
vi.mock("../domain/lifecycle/options", () => ({
normalizeGarbageCollectImagesOptions: (o: unknown) => o || {},
}));
vi.mock("../domain/maintenance/images", () => ({
findOrphanedSandboxImages: vi.fn().mockReturnValue([]),
parseSandboxImageRows: vi.fn().mockReturnValue([]),
}));
// ../domain/maintenance/images is left unmocked so the gc tests run the real
// orphan-detection helpers and can assert on gc's actual output.

import { backupAll, shouldSkipUnreachableSandboxBackup } from "./maintenance";
import { backupAll, garbageCollectImages, shouldSkipUnreachableSandboxBackup } from "./maintenance";
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe("backupAll", () => {
beforeEach(() => {
Expand Down Expand Up @@ -350,3 +348,40 @@ describe("shouldSkipUnreachableSandboxBackup", () => {
expect(shouldSkipUnreachableSandboxBackup({})).toBe(false);
});
});

describe("garbageCollectImages", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("surfaces a local-repo orphan while preserving a registered local image (#6301)", async () => {
// Local repo holds an orphan (gc-test-orphan-111) plus a still-registered
// image (live-222); the gateway repo holds only an in-use image.
mocks.dockerListImagesFormat.mockImplementation((repo: string) =>
repo === "nemoclaw-sandbox-local"
? "nemoclaw-sandbox-local:gc-test-orphan-111\t3GB\nnemoclaw-sandbox-local:live-222\t2GB"
: "openshell/sandbox-from:in-use\t1GB",
);
mocks.listSandboxes.mockReturnValue({
sandboxes: [
{ imageTag: "nemoclaw-sandbox-local:live-222" },
{ imageTag: "openshell/sandbox-from:in-use" },
],
defaultSandbox: null,
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);

await garbageCollectImages({ dryRun: true });

const out = logSpy.mock.calls.flat().join("\n");
logSpy.mockRestore();

// The local orphan is reported, the still-registered local image is not,
// and both repos are scanned.
expect(out).toContain("nemoclaw-sandbox-local:gc-test-orphan-111");
expect(out).not.toContain("nemoclaw-sandbox-local:live-222");
const scannedRepos = mocks.dockerListImagesFormat.mock.calls.map((call) => call[0]);
expect(scannedRepos).toContain("openshell/sandbox-from");
expect(scannedRepos).toContain("nemoclaw-sandbox-local");
});
});
10 changes: 6 additions & 4 deletions src/lib/actions/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
normalizeGarbageCollectImagesOptions,
} from "../domain/lifecycle/options";
import { findOrphanedSandboxImages, parseSandboxImageRows } from "../domain/maintenance/images";
import { SANDBOX_IMAGE_REPOS } from "../domain/sandbox/image-tag";
import { captureSandboxListWithGatewayPreflightOrExit } from "../openshell-sandbox-list";
import { parseReadySandboxNames } from "../runtime-recovery";
import * as registry from "../state/registry";
Expand Down Expand Up @@ -147,10 +148,11 @@ export async function garbageCollectImages(

let imagesOutput = "";
try {
imagesOutput = dockerListImagesFormat(
"openshell/sandbox-from",
"{{.Repository}}:{{.Tag}}\t{{.Size}}",
);
// Scan every sandbox image repo, not just sandbox-from; see
// SANDBOX_IMAGE_REPOS for why local prebuilds were missed (#6301).
imagesOutput = SANDBOX_IMAGE_REPOS.map((repo) =>
dockerListImagesFormat(repo, "{{.Repository}}:{{.Tag}}\t{{.Size}}"),
).join("\n");
} catch {
console.error(" Failed to query Docker images. Is Docker running?");
process.exit(1);
Expand Down
19 changes: 19 additions & 0 deletions src/lib/domain/maintenance/images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,23 @@ describe("maintenance image helpers", () => {
),
).toEqual([{ tag: "openshell/sandbox-from:two", size: "2GB" }]);
});

it("orphans a local image while keeping a registered local image (#6301)", () => {
// A locally prebuilt sandbox left an orphan under nemoclaw-sandbox-local;
// the matcher must flag it by tag regardless of repo, and preserve the
// still-registered local image of another sandbox.
expect(
findOrphanedSandboxImages(
[
{ tag: "openshell/sandbox-from:one", size: "1GB" },
{ tag: "nemoclaw-sandbox-local:live-222", size: "2GB" },
{ tag: "nemoclaw-sandbox-local:gc-test-111", size: "3GB" },
],
[
{ imageTag: "openshell/sandbox-from:one" },
{ imageTag: "nemoclaw-sandbox-local:live-222" },
],
),
).toEqual([{ tag: "nemoclaw-sandbox-local:gc-test-111", size: "3GB" }]);
});
});
19 changes: 18 additions & 1 deletion src/lib/domain/sandbox/image-tag.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/** Gateway-built sandbox images (openshell sandbox create). */
export const SANDBOX_FROM_IMAGE_REPO = "openshell/sandbox-from";
/**
* Locally prebuilt sandbox images, tagged by the docker-driver-gateway path
* (Linux, or macOS on Apple Silicon — see isLinuxDockerDriverGatewayEnabled).
*/
export const LOCAL_SANDBOX_IMAGE_REPO = "nemoclaw-sandbox-local";

/**
* Every Docker repository that can hold a sandbox image. Any orphan sweep
* (`nemoclaw gc`) must enumerate all of them: locally prebuilt sandboxes are
* tagged under LOCAL_SANDBOX_IMAGE_REPO, not the gateway-side
* SANDBOX_FROM_IMAGE_REPO, so scanning only the latter left local orphans
* invisible to gc (#6301).
*/
export const SANDBOX_IMAGE_REPOS = [SANDBOX_FROM_IMAGE_REPO, LOCAL_SANDBOX_IMAGE_REPO] as const;

const BUILT_SANDBOX_IMAGE_RE = /Built image (openshell\/sandbox-from:\d+)/;

export function resolveSandboxImageTagFromCreateOutput(
Expand All @@ -16,5 +33,5 @@ export function resolveSandboxImageTagFromCreateOutput(
warn(
" Warning: could not parse image tag from build output; imageTag may be stale. Run 'nemoclaw gc' if destroy fails.",
);
return `openshell/sandbox-from:${buildId}`;
return `${SANDBOX_FROM_IMAGE_REPO}:${buildId}`;
}
3 changes: 2 additions & 1 deletion src/lib/onboard/sandbox-prebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";

import { dockerSpawn } from "../adapters/docker/exec";
import { LOCAL_SANDBOX_IMAGE_REPO } from "../domain/sandbox/image-tag";
import {
SANDBOX_BUILD_CONTEXT_PREFIX,
type SandboxBuildContextOrigin,
Expand All @@ -14,7 +15,7 @@ import { buildSubprocessEnv } from "../subprocess-env";

const TRUTHY_FLAG_VALUES = new Set(["1", "true", "yes", "on"]);
const FALSY_FLAG_VALUES = new Set(["0", "false", "no", "off"]);
const LOCAL_IMAGE_REPO = "nemoclaw-sandbox-local";
const LOCAL_IMAGE_REPO = LOCAL_SANDBOX_IMAGE_REPO;
const DOCKER_ENV_NAMES = [
"DOCKER_API_VERSION",
"DOCKER_CERT_PATH",
Expand Down