-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix(state): restore preserved backups into replacement sandboxes #7814
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
8aa98a6
fix(state): restore preserved backups into replacement sandboxes
laitingsheng 305a818
Merge remote-tracking branch 'origin/main' into fix/portable-backup-r…
laitingsheng 30d5d1a
test(state): reach the post-swap sqlite write probe
laitingsheng b7e8746
Merge remote-tracking branch 'origin/main' into fix/portable-backup-r…
laitingsheng 2368cbd
Merge remote-tracking branch 'origin/main' into fix/portable-backup-r…
laitingsheng e8d7044
merge: resolve conflicts with main
github-actions[bot] 822dd48
Merge branch 'main' into fix/portable-backup-restore
laitingsheng 7aa406d
Merge remote-tracking branch 'origin/main' into fix/portable-backup-r…
laitingsheng 12dec40
ci: lower the shell-quote fan-in limit to the measured value
laitingsheng 99e387c
Merge branch 'main' into fix/portable-backup-restore
cv cd77c32
docs(state): clarify offline restore prerequisites
cv d7cd830
Merge branch 'main' into fix/portable-backup-restore
cv 3eb94e6
Merge branch 'main' into fix/portable-backup-restore
cv cd9c0bb
test(state): cover offline restore route guard
cv 20e5a55
Merge remote-tracking branch 'origin/main' into fix/portable-backup-r…
laitingsheng File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
101 changes: 101 additions & 0 deletions
101
src/lib/actions/sandbox/snapshot-restore-offline-source.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import * as f from "./snapshot-restore-test-fixture"; | ||
|
|
||
| const offlineSourceEntry: { | ||
| name: string; | ||
| agent: string; | ||
| imageTag: string | null; | ||
| openshellDriver: string; | ||
| provider: string | null; | ||
| model: string | null; | ||
| } = { | ||
| name: "alpha", | ||
| agent: "openclaw", | ||
| imageTag: "nemoclaw-alpha:test", | ||
| openshellDriver: "docker", | ||
| provider: "nvidia-nim", | ||
| model: "nvidia/model-a", | ||
| }; | ||
|
|
||
| function stubOfflineSource(entry: typeof offlineSourceEntry): void { | ||
| f.getSandboxMock.mockImplementation((name) => (name === "alpha" ? entry : null)); | ||
| f.parseLiveSandboxNamesMock.mockReturnValue(new Set<string>()); | ||
| f.captureOpenshellMock.mockImplementation((args) => | ||
| f.openshellResponses(args, { | ||
| "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, | ||
| "sandbox list": { status: 0, output: "beta Ready\n" }, | ||
| }), | ||
| ); | ||
| f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| f.resetSnapshotRestoreMocks(); | ||
| }); | ||
| afterEach(() => { | ||
| f.cleanupSnapshotRestoreMocks(); | ||
| }); | ||
|
|
||
| describe("runSandboxSnapshot restore: source sandbox no longer running", () => { | ||
| it("restores into a replacement sandbox built from the registered source image", async () => { | ||
| vi.spyOn(console, "log").mockImplementation(() => {}); | ||
| stubOfflineSource(offlineSourceEntry); | ||
| f.restoreSandboxStateMock.mockReturnValue({ | ||
| success: true, | ||
| restoredDirs: ["workspace"], | ||
| restoredFiles: ["user.md"], | ||
| failedDirs: [], | ||
| failedFiles: [], | ||
| }); | ||
| const { runSandboxSnapshot } = await import("./snapshot"); | ||
|
|
||
| await runSandboxSnapshot("alpha", { kind: "restore", to: "beta", yes: true }); | ||
|
|
||
| expect(f.streamSandboxCreateMock).toHaveBeenCalledWith( | ||
| expect.any(String), | ||
| expect.arrayContaining(["--name", "beta", "--from", "nemoclaw-alpha:test"]), | ||
| expect.any(Object), | ||
| expect.any(Object), | ||
| ); | ||
| expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha"); | ||
| }); | ||
|
|
||
| it("stops before creating a replacement when the source records no image", async () => { | ||
| vi.spyOn(console, "log").mockImplementation(() => {}); | ||
| const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); | ||
| stubOfflineSource({ ...offlineSourceEntry, imageTag: null }); | ||
| const { runSandboxSnapshot } = await import("./snapshot"); | ||
|
|
||
| await expect( | ||
| runSandboxSnapshot("alpha", { kind: "restore", to: "beta", yes: true }), | ||
| ).rejects.toMatchObject({ exitCode: 1 }); | ||
|
|
||
| const errors = consoleError.mock.calls.flat().join("\n"); | ||
| expect(errors).toContain( | ||
| "source 'alpha' is not running and its registry entry records no image", | ||
| ); | ||
| expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); | ||
| expect(f.restoreSandboxStateMock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("stops before creating a replacement when the source inference route is incomplete", async () => { | ||
| vi.spyOn(console, "log").mockImplementation(() => {}); | ||
| const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); | ||
| stubOfflineSource({ ...offlineSourceEntry, model: null }); | ||
| const { runSandboxSnapshot } = await import("./snapshot"); | ||
|
|
||
| await expect( | ||
| runSandboxSnapshot("alpha", { kind: "restore", to: "beta", yes: true }), | ||
| ).rejects.toMatchObject({ exitCode: 1 }); | ||
|
|
||
| expect(consoleError.mock.calls.flat().join("\n")).toContain( | ||
| "source 'alpha' has no complete durable inference route", | ||
| ); | ||
| expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); | ||
| expect(f.restoreSandboxStateMock).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { execFileSync, spawnSync } from "node:child_process"; | ||
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| import { afterEach, describe, expect, it } from "vitest"; | ||
|
|
||
| import { buildStateFileRestoreCommand } from "./state-file-restore"; | ||
|
|
||
| const CREATE_SOURCE_DB_PY = [ | ||
| "import sqlite3, sys", | ||
| "conn = sqlite3.connect(sys.argv[1])", | ||
| "conn.execute('CREATE TABLE sessions (id TEXT)')", | ||
| "conn.execute(\"INSERT INTO sessions VALUES ('restored')\")", | ||
| "conn.commit()", | ||
| "conn.close()", | ||
| ].join("\n"); | ||
|
|
||
| const WRITE_TO_DB_PY = [ | ||
| "import sqlite3, sys", | ||
| "conn = sqlite3.connect(sys.argv[1])", | ||
| "conn.execute(\"INSERT INTO sessions VALUES ('after-restore')\")", | ||
| "conn.commit()", | ||
| "print(conn.execute('SELECT count(*) FROM sessions').fetchone()[0])", | ||
| "conn.close()", | ||
| ].join("\n"); | ||
|
|
||
| const hasSystemPython = fs.existsSync("/usr/bin/python3"); | ||
| const fixtures: string[] = []; | ||
|
|
||
| function makeFixture(): { dir: string; backup: Buffer } { | ||
| const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sqlite-restore-")); | ||
| fixtures.push(dir); | ||
| const sourceDb = path.join(dir, "source.db"); | ||
| execFileSync("/usr/bin/python3", ["-c", CREATE_SOURCE_DB_PY, sourceDb]); | ||
| return { dir, backup: fs.readFileSync(sourceDb) }; | ||
| } | ||
|
|
||
| function occupyRollbackJournalPath(restored: string): void { | ||
| fs.mkdirSync(`${restored}-journal`, { recursive: true }); | ||
| } | ||
|
|
||
| function runRestore(stateDir: string, backup: Buffer): { status: number | null; stderr: string } { | ||
| const command = buildStateFileRestoreCommand(stateDir, { | ||
| path: "runtime/state.db", | ||
| strategy: "sqlite_backup", | ||
| }); | ||
| const result = spawnSync("bash", ["-c", command], { input: backup }); | ||
| return { status: result.status, stderr: result.stderr.toString() }; | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| for (const fixture of fixtures.splice(0)) { | ||
| fs.rmSync(fixture, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| describe.skipIf(!hasSystemPython)("sqlite state-file restore", () => { | ||
| it("leaves the restored database writable and free of stale sidecars", () => { | ||
| const { dir, backup } = makeFixture(); | ||
| const stateDir = path.join(dir, "state"); | ||
| const restored = path.join(stateDir, "runtime", "state.db"); | ||
| fs.mkdirSync(path.join(stateDir, "runtime"), { recursive: true }); | ||
| fs.writeFileSync(`${restored}-wal`, "stale"); | ||
| fs.writeFileSync(`${restored}-shm`, "stale"); | ||
|
|
||
| const result = runRestore(stateDir, backup); | ||
|
|
||
| expect(result.stderr).toBe(""); | ||
| expect(result.status).toBe(0); | ||
| expect(fs.existsSync(`${restored}-wal`)).toBe(false); | ||
| expect(fs.existsSync(`${restored}-shm`)).toBe(false); | ||
| const rows = execFileSync("/usr/bin/python3", ["-c", WRITE_TO_DB_PY, restored]).toString(); | ||
| expect(rows.trim()).toBe("2"); | ||
| }); | ||
|
|
||
| it("reports failure when the swapped database cannot open a write transaction", () => { | ||
| const { dir, backup } = makeFixture(); | ||
| const stateDir = path.join(dir, "state"); | ||
| const restored = path.join(stateDir, "runtime", "state.db"); | ||
| fs.mkdirSync(path.join(stateDir, "runtime"), { recursive: true }); | ||
| occupyRollbackJournalPath(restored); | ||
|
|
||
| const result = runRestore(stateDir, backup); | ||
|
|
||
| expect(fs.existsSync(restored)).toBe(true); | ||
| expect(result.stderr).toContain(`restored database is not writable: ${restored}`); | ||
| expect(result.status).toBe(12); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.