mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(ai): add a Reset AI Environment admin feature for the upgrade gap (#459)
Uninstalling a bundle only deletes its downloaded model weights, never the
shared venv's site-packages, so self-hosters who already hit an AI bundle
conflict (e.g. the scipy ABI strand) have no clean self-service path via
uninstall+reinstall: reinstalling just overlays corrected files on top of
stale ones. Adds POST /api/v1/admin/features/reset, which wipes
/data/ai/{venv,models,pip-cache}, resets installed.json, and reseeds a real
working venv from the image's baked /opt/venv (extracted docker/reseed-ai-venv.sh,
now shared with entrypoint.sh's existing base-venv-upgrade bootstrap instead
of duplicating that logic) -- leaving an empty venv directory here would
make the very next install fail with "spawn .../python3 ENOENT", caught by
testing this live rather than assuming it. Ships with a matching Settings UI
section (inline confirm, same pattern as per-bundle uninstall) and strings
across all 21 locales.
Verified against a real snapotter/snapotter:1.17.2 image migrated to 2.0.0,
with real multi-GB bundles installed (background-removal + OCR): confirmed
the migrated instance's inherited python3.11 venv (2.0.0 itself uses 3.12)
still imports the fixed scipy/numpy/paddleocr correctly, then reset + real
reinstall + actual tool execution (remove-background, verified output image)
all worked end-to-end.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Integration tests for POST /api/v1/admin/features/reset at the HTTP route
|
||||
* level: wipes the AI venv/models/pip-cache and resets installed.json, so
|
||||
* existing installs stuck with a stale/conflicting venv (uninstall alone only
|
||||
* removes model weights, never the shared site-packages) have a reliable way
|
||||
* to get back to a clean slate rather than overlaying corrected files on top
|
||||
* of stale ones.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
shutdownDispatcherMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@snapotter/ai", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, shutdownDispatcher: hoisted.shutdownDispatcherMock };
|
||||
});
|
||||
|
||||
// ── Temp DATA_DIR before importing feature-status ────────────────
|
||||
const testRoot = join(tmpdir(), `snapotter-feature-reset-${randomUUID()}`);
|
||||
const aiDir = join(testRoot, "ai");
|
||||
const modelsDir = join(aiDir, "models");
|
||||
const venvDir = join(aiDir, "venv");
|
||||
const installedPath = join(aiDir, "installed.json");
|
||||
const lockPath = join(aiDir, "install.lock");
|
||||
|
||||
process.env.DATA_DIR = testRoot;
|
||||
// Point at the real manifest so isDockerEnvironment() is true and
|
||||
// ensureAiDirs() actually recreates the skeleton after a reset.
|
||||
process.env.FEATURE_MANIFEST_PATH = join(process.cwd(), "docker/feature-manifest.json");
|
||||
|
||||
mkdirSync(modelsDir, { recursive: true });
|
||||
writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8");
|
||||
|
||||
const { markInstalled, invalidateCache, releaseInstallLock, acquireInstallLock } = await import(
|
||||
"../../../apps/api/src/lib/feature-status.js"
|
||||
);
|
||||
const { loginAsAdmin } = await import("../test-server.js");
|
||||
|
||||
describe("POST /api/v1/admin/features/reset", () => {
|
||||
let app: Awaited<ReturnType<typeof import("fastify")>>["default"] extends (
|
||||
...args: infer _A
|
||||
) => infer R
|
||||
? R
|
||||
: never;
|
||||
let token: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const Fastify = (await import("fastify")).default;
|
||||
const multipartPlugin = (await import("@fastify/multipart")).default;
|
||||
const cookie = (await import("@fastify/cookie")).default;
|
||||
const cors = (await import("@fastify/cors")).default;
|
||||
|
||||
app = Fastify({ logger: false, bodyLimit: 100 * 1024 * 1024 });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(multipartPlugin, { limits: { fileSize: 100 * 1024 * 1024 } });
|
||||
await app.register(cookie, { secret: "test-cookie-secret", hook: "onRequest" });
|
||||
|
||||
const { authMiddleware, authRoutes, ensureBuiltinRoles, ensureDefaultAdmin } = await import(
|
||||
"../../../apps/api/src/plugins/auth.js"
|
||||
);
|
||||
await authMiddleware(app);
|
||||
await authRoutes(app);
|
||||
await ensureBuiltinRoles();
|
||||
await ensureDefaultAdmin();
|
||||
|
||||
const { db, schema } = await import("../../../apps/api/src/db/index.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ mustChangePassword: false })
|
||||
.where(eq(schema.users.username, "admin"));
|
||||
|
||||
const { registerFeatureRoutes } = await import("../../../apps/api/src/routes/features.js");
|
||||
await registerFeatureRoutes(app);
|
||||
|
||||
token = await loginAsAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
try {
|
||||
releaseInstallLock();
|
||||
} catch {
|
||||
// no lock held
|
||||
}
|
||||
writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8");
|
||||
invalidateCache();
|
||||
hoisted.shutdownDispatcherMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
releaseInstallLock();
|
||||
} catch {
|
||||
// no lock held
|
||||
}
|
||||
});
|
||||
|
||||
const auth = () => ({ authorization: `Bearer ${token}` });
|
||||
|
||||
async function postReset() {
|
||||
return app.inject({ method: "POST", url: "/api/v1/admin/features/reset", headers: auth() });
|
||||
}
|
||||
|
||||
it("requires auth", async () => {
|
||||
const res = await app.inject({ method: "POST", url: "/api/v1/admin/features/reset" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("wipes the venv, models, and installed.json, and returns ok", async () => {
|
||||
markInstalled("ocr", "2.0.0", ["paddleocr-server-det"]);
|
||||
mkdirSync(join(venvDir, "lib", "python3.12", "site-packages"), { recursive: true });
|
||||
writeFileSync(join(modelsDir, "leftover.onnx"), "stale weights");
|
||||
|
||||
const res = await postReset();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true });
|
||||
expect(existsSync(join(venvDir, "lib"))).toBe(false);
|
||||
expect(existsSync(join(modelsDir, "leftover.onnx"))).toBe(false);
|
||||
const installed = JSON.parse(readFileSync(installedPath, "utf-8"));
|
||||
expect(installed.bundles).toEqual({});
|
||||
});
|
||||
|
||||
it("shuts down the dispatcher so the next AI request starts fresh", async () => {
|
||||
await postReset();
|
||||
expect(hoisted.shutdownDispatcherMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 409 instead of tearing anything down when a bundle install is in progress", async () => {
|
||||
markInstalled("ocr", "2.0.0", ["paddleocr-server-det"]);
|
||||
acquireInstallLock("ocr");
|
||||
|
||||
const res = await postReset();
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toMatch(/install.*progress/i);
|
||||
// Nothing torn down: the bundle is still marked installed.
|
||||
const installed = JSON.parse(readFileSync(installedPath, "utf-8"));
|
||||
expect(installed.bundles).toHaveProperty("ocr");
|
||||
expect(existsSync(lockPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -276,6 +276,70 @@ describe("Install lock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetAiEnvironment", () => {
|
||||
function markDockerEnvironment() {
|
||||
// isDockerEnvironment() checks for the manifest path, which the
|
||||
// beforeEach already points at a file under tempDir; write something
|
||||
// there so ensureAiDirs() actually recreates the skeleton afterward.
|
||||
writeFileSync(process.env.FEATURE_MANIFEST_PATH ?? "", JSON.stringify({ bundles: {} }));
|
||||
}
|
||||
|
||||
it("removes venv, models, and pip-cache directories", () => {
|
||||
markDockerEnvironment();
|
||||
const venvDir = join(aiDir, "venv");
|
||||
const pipCacheDir = join(aiDir, "pip-cache");
|
||||
mkdirSync(join(venvDir, "lib", "python3.12", "site-packages", "scipy"), { recursive: true });
|
||||
writeFileSync(join(modelsDir, "some-model.onnx"), "fake weights");
|
||||
mkdirSync(pipCacheDir, { recursive: true });
|
||||
writeFileSync(join(pipCacheDir, "cached.whl"), "fake wheel");
|
||||
|
||||
mod.resetAiEnvironment();
|
||||
|
||||
expect(existsSync(join(venvDir, "lib"))).toBe(false);
|
||||
expect(existsSync(join(modelsDir, "some-model.onnx"))).toBe(false);
|
||||
expect(existsSync(join(pipCacheDir, "cached.whl"))).toBe(false);
|
||||
});
|
||||
|
||||
it("resets installed.json to empty", () => {
|
||||
markDockerEnvironment();
|
||||
mod.markInstalled("ocr", "2.0.0", ["paddleocr-server-det"]);
|
||||
mod.markInstalled("background-removal", "2.0.0", ["rembg-u2net"]);
|
||||
expect(mod.isFeatureInstalled("ocr")).toBe(true);
|
||||
|
||||
mod.resetAiEnvironment();
|
||||
|
||||
const data = JSON.parse(readFileSync(installedPath, "utf-8"));
|
||||
expect(data.bundles).toEqual({});
|
||||
expect(mod.isFeatureInstalled("ocr")).toBe(false);
|
||||
expect(mod.isFeatureInstalled("background-removal")).toBe(false);
|
||||
});
|
||||
|
||||
it("recreates an empty directory skeleton so a fresh install has somewhere to write", () => {
|
||||
markDockerEnvironment();
|
||||
mod.resetAiEnvironment();
|
||||
|
||||
expect(existsSync(join(aiDir, "venv"))).toBe(true);
|
||||
expect(existsSync(modelsDir)).toBe(true);
|
||||
expect(existsSync(join(aiDir, "pip-cache"))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to reset while a bundle install is in progress", () => {
|
||||
markDockerEnvironment();
|
||||
mod.acquireInstallLock("ocr");
|
||||
|
||||
expect(() => mod.resetAiEnvironment()).toThrow(/install.*progress/i);
|
||||
|
||||
// Nothing should have been torn down.
|
||||
expect(existsSync(lockPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("releases its own lock after completing", () => {
|
||||
markDockerEnvironment();
|
||||
mod.resetAiEnvironment();
|
||||
expect(existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feature status queries", () => {
|
||||
it("isFeatureInstalled returns true for installed bundle", () => {
|
||||
mod.markInstalled("background-removal", "1.0.0", []);
|
||||
|
||||
@@ -223,6 +223,49 @@ describe("useFeaturesStore (expanded)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetEnvironment", () => {
|
||||
it("posts to the reset endpoint and refreshes bundles on success", async () => {
|
||||
apiPostMock.mockResolvedValueOnce({});
|
||||
apiGetMock.mockResolvedValueOnce({ bundles: [] });
|
||||
|
||||
await useFeaturesStore.getState().resetEnvironment();
|
||||
|
||||
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/reset", {});
|
||||
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
|
||||
expect(useFeaturesStore.getState().resetError).toBeNull();
|
||||
});
|
||||
|
||||
it("clears stale installing/errors/queued state on success", async () => {
|
||||
useFeaturesStore.setState({
|
||||
installing: { ocr: { percent: 40, stage: "downloading" } },
|
||||
errors: { ocr: "some old error" },
|
||||
queued: ["ocr"],
|
||||
startTimes: { ocr: Date.now() },
|
||||
});
|
||||
apiPostMock.mockResolvedValueOnce({});
|
||||
apiGetMock.mockResolvedValueOnce({ bundles: [] });
|
||||
|
||||
await useFeaturesStore.getState().resetEnvironment();
|
||||
|
||||
const state = useFeaturesStore.getState();
|
||||
expect(state.installing).toEqual({});
|
||||
expect(state.errors).toEqual({});
|
||||
expect(state.queued).toEqual([]);
|
||||
expect(state.startTimes).toEqual({});
|
||||
});
|
||||
|
||||
it("sets resetError on failure and does not refresh bundles", async () => {
|
||||
apiPostMock.mockRejectedValueOnce(new Error("a bundle install is already in progress"));
|
||||
|
||||
await useFeaturesStore.getState().resetEnvironment();
|
||||
|
||||
expect(useFeaturesStore.getState().resetError).toBe(
|
||||
"a bundle install is already in progress",
|
||||
);
|
||||
expect(apiGetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearError", () => {
|
||||
it("does not affect other errors when clearing one", () => {
|
||||
useFeaturesStore.setState({
|
||||
|
||||
Reference in New Issue
Block a user