diff --git a/backend/src/__tests__/blueprints-withdraw-route.test.ts b/backend/src/__tests__/blueprints-withdraw-route.test.ts new file mode 100644 index 00000000..c8771d6d --- /dev/null +++ b/backend/src/__tests__/blueprints-withdraw-route.test.ts @@ -0,0 +1,288 @@ +/** + * Route-level tests for POST /api/blueprints/:id/withdraw/:nodeId. + * Covers the snapshot-before-evict wiring: snapshot_then_evict captures a + * fleet_snapshots row + one fleet_snapshot_files row, and aborts the eviction + * (without invoking withdrawFromNode) when the snapshot write fails. Also + * verifies evict_and_destroy and stateless withdraws do not create snapshots. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let BlueprintService: typeof import('../services/BlueprintService').BlueprintService; +let adminCookie: string; +let counter = 0; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ LicenseService } = await import('../services/LicenseService')); + ({ BlueprintService } = await import('../services/BlueprintService')); + + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + const db = DatabaseService.getInstance().getDb(); + db.prepare('DELETE FROM blueprint_deployments').run(); + db.prepare('DELETE FROM blueprints').run(); + db.prepare('DELETE FROM fleet_snapshot_files').run(); + db.prepare('DELETE FROM fleet_snapshots').run(); + db.prepare("DELETE FROM nodes WHERE is_default = 0").run(); +}); + +function seedNode(): { id: number; name: string } { + counter += 1; + const name = `bp-route-${counter}`; + const db = DatabaseService.getInstance().getDb(); + const result = db.prepare( + `INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at) + VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)` + ).run(name, Date.now()); + return { id: result.lastInsertRowid as number, name }; +} + +function seedBlueprint(opts: { + classification: 'stateless' | 'stateful' | 'unknown'; + nodeIds: number[]; + composeContent?: string; +}) { + counter += 1; + return DatabaseService.getInstance().createBlueprint({ + name: `bp-${counter}`, + description: null, + compose_content: opts.composeContent ?? 'services:\n app:\n image: nginx\n', + selector: { type: 'nodes', ids: opts.nodeIds }, + drift_mode: 'suggest', + classification: opts.classification, + classification_reasons: [], + enabled: true, + created_by: 'admin', + }); +} + +function seedActiveDeployment(blueprintId: number, nodeId: number, revision: number) { + DatabaseService.getInstance().upsertDeployment({ + blueprint_id: blueprintId, + node_id: nodeId, + status: 'active', + applied_revision: revision, + }); +} + +describe('POST /api/blueprints/:id/withdraw/:nodeId', () => { + it('snapshot_then_evict on a stateful blueprint captures compose into fleet_snapshots, then withdraws', async () => { + const node = seedNode(); + const compose = 'services:\n db:\n image: postgres:16\n volumes:\n - data:/var/lib/postgresql/data\nvolumes:\n data:\n'; + const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id], composeContent: compose }); + seedActiveDeployment(bp.id, node.id, bp.revision); + + const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode') + .mockResolvedValue({ status: 'withdrawn' }); + + const res = await request(app) + .post(`/api/blueprints/${bp.id}/withdraw/${node.id}`) + .set('Cookie', adminCookie) + .send({ confirm: 'snapshot_then_evict' }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('withdrawn'); + expect(res.body.snapshotPolicy).toBe('snapshot_then_evict'); + expect(typeof res.body.snapshotId).toBe('number'); + expect(withdrawSpy).toHaveBeenCalledOnce(); + + const db = DatabaseService.getInstance().getDb(); + const snapRow = db.prepare('SELECT * FROM fleet_snapshots WHERE id = ?').get(res.body.snapshotId) as { + description: string; + node_count: number; + stack_count: number; + }; + expect(snapRow).toBeDefined(); + expect(snapRow.description).toBe(`Pre-eviction: blueprint=${bp.name} node=${node.name}`); + expect(snapRow.node_count).toBe(1); + expect(snapRow.stack_count).toBe(1); + + const fileRows = db.prepare('SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ?') + .all(res.body.snapshotId) as Array<{ stack_name: string; filename: string; content: string; node_id: number }>; + expect(fileRows).toHaveLength(1); + expect(fileRows[0].stack_name).toBe(bp.name); + expect(fileRows[0].filename).toBe('docker-compose.yml'); + expect(fileRows[0].content).toBe(compose); + expect(fileRows[0].node_id).toBe(node.id); + }); + + it('aborts the eviction with 500 and does NOT call withdrawFromNode when the snapshot write fails', async () => { + const node = seedNode(); + const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id] }); + seedActiveDeployment(bp.id, node.id, bp.revision); + + const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode') + .mockResolvedValue({ status: 'withdrawn' }); + vi.spyOn(DatabaseService.getInstance(), 'createSnapshot').mockImplementation(() => { + throw new Error('disk full'); + }); + + const res = await request(app) + .post(`/api/blueprints/${bp.id}/withdraw/${node.id}`) + .set('Cookie', adminCookie) + .send({ confirm: 'snapshot_then_evict' }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('snapshot_failed'); + expect(withdrawSpy).not.toHaveBeenCalled(); + + const db = DatabaseService.getInstance().getDb(); + const count = (db.prepare('SELECT COUNT(*) as n FROM fleet_snapshots').get() as { n: number }).n; + expect(count).toBe(0); + }); + + it('cleans up the orphan snapshot row when insertSnapshotFiles fails after createSnapshot succeeded', async () => { + const node = seedNode(); + const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id] }); + seedActiveDeployment(bp.id, node.id, bp.revision); + + const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode') + .mockResolvedValue({ status: 'withdrawn' }); + vi.spyOn(DatabaseService.getInstance(), 'insertSnapshotFiles').mockImplementation(() => { + throw new Error('constraint violation'); + }); + + const res = await request(app) + .post(`/api/blueprints/${bp.id}/withdraw/${node.id}`) + .set('Cookie', adminCookie) + .send({ confirm: 'snapshot_then_evict' }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('snapshot_failed'); + expect(withdrawSpy).not.toHaveBeenCalled(); + + const db = DatabaseService.getInstance().getDb(); + const count = (db.prepare('SELECT COUNT(*) as n FROM fleet_snapshots').get() as { n: number }).n; + expect(count).toBe(0); + }); + + it('evict_and_destroy on a stateful blueprint does NOT create a snapshot', async () => { + const node = seedNode(); + const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id] }); + seedActiveDeployment(bp.id, node.id, bp.revision); + + vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode') + .mockResolvedValue({ status: 'withdrawn' }); + + const res = await request(app) + .post(`/api/blueprints/${bp.id}/withdraw/${node.id}`) + .set('Cookie', adminCookie) + .send({ confirm: 'evict_and_destroy' }); + + expect(res.status).toBe(200); + expect(res.body.snapshotPolicy).toBe('evict_and_destroy'); + expect(res.body.snapshotId).toBeNull(); + + const db = DatabaseService.getInstance().getDb(); + const count = (db.prepare('SELECT COUNT(*) as n FROM fleet_snapshots').get() as { n: number }).n; + expect(count).toBe(0); + }); + + it('standard withdraw on a stateless blueprint does NOT create a snapshot', async () => { + const node = seedNode(); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [node.id] }); + seedActiveDeployment(bp.id, node.id, bp.revision); + + vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode') + .mockResolvedValue({ status: 'withdrawn' }); + + const res = await request(app) + .post(`/api/blueprints/${bp.id}/withdraw/${node.id}`) + .set('Cookie', adminCookie) + .send({ confirm: 'standard' }); + + expect(res.status).toBe(200); + expect(res.body.snapshotPolicy).toBe('standard'); + expect(res.body.snapshotId).toBeNull(); + + const db = DatabaseService.getInstance().getDb(); + const count = (db.prepare('SELECT COUNT(*) as n FROM fleet_snapshots').get() as { n: number }).n; + expect(count).toBe(0); + }); + + it('rejects standard withdraw on a stateful blueprint with evict_blocked', async () => { + const node = seedNode(); + const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id] }); + seedActiveDeployment(bp.id, node.id, bp.revision); + + const res = await request(app) + .post(`/api/blueprints/${bp.id}/withdraw/${node.id}`) + .set('Cookie', adminCookie) + .send({ confirm: 'standard' }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('evict_blocked'); + }); + + it('rejects an unknown confirm mode with 400', async () => { + const node = seedNode(); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [node.id] }); + + const res = await request(app) + .post(`/api/blueprints/${bp.id}/withdraw/${node.id}`) + .set('Cookie', adminCookie) + .send({ confirm: 'wrong_mode' }); + + expect(res.status).toBe(400); + }); + + it('defaults to standard withdraw when the confirm field is omitted', async () => { + const node = seedNode(); + const bp = seedBlueprint({ classification: 'stateless', nodeIds: [node.id] }); + seedActiveDeployment(bp.id, node.id, bp.revision); + + vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode') + .mockResolvedValue({ status: 'withdrawn' }); + + const res = await request(app) + .post(`/api/blueprints/${bp.id}/withdraw/${node.id}`) + .set('Cookie', adminCookie) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.snapshotPolicy).toBe('standard'); + expect(res.body.snapshotId).toBeNull(); + }); + + it('snapshot_then_evict returns 500 when compose_content is empty', async () => { + const node = seedNode(); + const bp = seedBlueprint({ classification: 'stateful', nodeIds: [node.id], composeContent: ' \n \n' }); + seedActiveDeployment(bp.id, node.id, bp.revision); + + const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode') + .mockResolvedValue({ status: 'withdrawn' }); + + const res = await request(app) + .post(`/api/blueprints/${bp.id}/withdraw/${node.id}`) + .set('Cookie', adminCookie) + .send({ confirm: 'snapshot_then_evict' }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('snapshot_failed'); + expect(withdrawSpy).not.toHaveBeenCalled(); + + const db = DatabaseService.getInstance().getDb(); + const count = (db.prepare('SELECT COUNT(*) as n FROM fleet_snapshots').get() as { n: number }).n; + expect(count).toBe(0); + }); +}); diff --git a/backend/src/routes/blueprints.ts b/backend/src/routes/blueprints.ts index 4d384497..d3c63322 100644 --- a/backend/src/routes/blueprints.ts +++ b/backend/src/routes/blueprints.ts @@ -328,10 +328,53 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons }); return; } - // snapshot_then_evict: a v1 placeholder. The fleet-snapshot wiring is a separate feature; - // we record intent and proceed with a normal withdraw. v2 will perform the actual snapshot. + let snapshotId: number | null = null; + if (confirm === 'snapshot_then_evict') { + const compose = blueprint.compose_content; + if (!compose || compose.trim().length === 0) { + res.status(500).json({ + error: 'Blueprint has no compose content to snapshot', + code: 'snapshot_failed', + }); + return; + } + try { + const db = DatabaseService.getInstance(); + const username = req.user?.username ?? 'admin'; + snapshotId = db.createSnapshot( + `Pre-eviction: blueprint=${blueprint.name} node=${node.name}`, + username, + 1, + 1, + '[]', + ); + db.insertSnapshotFiles(snapshotId, [{ + nodeId: node.id, + nodeName: node.name, + stackName: blueprint.name, + filename: 'docker-compose.yml', + content: compose, + }]); + } catch (snapErr) { + console.error('[Blueprints] Pre-eviction snapshot failed:', snapErr); + if (snapshotId !== null) { + try { DatabaseService.getInstance().deleteSnapshot(snapshotId); } + catch (cleanupErr) { console.error('[Blueprints] Failed to clean up orphan snapshot row:', cleanupErr); } + } + res.status(500).json({ + error: 'Failed to capture compose snapshot before eviction', + code: 'snapshot_failed', + }); + return; + } + } const result = await BlueprintService.getInstance().withdrawFromNode(blueprint, node); - res.json({ status: result.status, error: result.error ?? null, snapshotPolicy: confirm }); + res.json({ + status: result.status, + error: result.error ?? null, + snapshotPolicy: confirm, + snapshotId, + }); } catch (error) { console.error('[Blueprints] Withdraw error:', error); res.status(500).json({ error: getErrorMessage(error, 'Failed to withdraw blueprint') }); diff --git a/docs/features/blueprint-model.mdx b/docs/features/blueprint-model.mdx index 0372921c..c1fde5a0 100644 --- a/docs/features/blueprint-model.mdx +++ b/docs/features/blueprint-model.mdx @@ -103,7 +103,11 @@ Click **Edit** on the detail sheet. Editing the compose bumps the revision; the ### Withdraw a single deployment -In the deployment table, click **Withdraw** on the node's row. For stateless blueprints, Sencho runs `docker compose down` and removes the directory. For stateful blueprints, you choose between **Snapshot, then evict** (records the compose definition to fleet snapshots, then evicts) and **Evict and destroy data** (typed-confirm, destroys named volumes). +In the deployment table, click **Withdraw** on the node's row. For stateless blueprints, Sencho runs `docker compose down` and removes the directory. For stateful blueprints, you choose between **Snapshot, then evict** (records the compose definition to Fleet → Snapshots, then evicts) and **Evict and destroy data** (typed-confirm, destroys named volumes). + + +**Snapshot, then evict** captures the compose definition only. Volume bytes are not shipped. The named volumes managed by this stack on the target node are removed by `docker compose down` just as with **Evict and destroy data**. To preserve data, capture volumes manually before withdrawing (see *Migrating stateful data between nodes* below). + ### Delete the blueprint @@ -111,11 +115,11 @@ Stateless blueprints withdraw all deployments and then delete. Stateful blueprin ## Migrating stateful data between nodes (manual) -Sencho's compose-native lane does not include automatic volume shipping. When you move a stateful Blueprint's data from node A to node B, do it by hand: +Sencho's compose-native lane does not include automatic volume shipping. **Snapshot, then evict** is a compose-only safety net: it preserves the YAML so you can redeploy elsewhere, but it does not move data. To relocate a stateful Blueprint's data from node A to node B, do it by hand before withdrawing: -1. Stop the Blueprint deployment on node A from the deployment table. -2. Use your existing host tooling (`docker run --rm -v :/data busybox tar -czf - /data > snapshot.tar.gz`, or app-aware tooling such as `pg_basebackup`/`mysqldump`/`mongodump`) to capture the volume. -3. Transfer the artifact to node B and restore it into the named volume. +1. Stop the Blueprint deployment on node A from the deployment table. *Tip:* use **Snapshot, then evict** so the compose YAML is parked in Fleet → Snapshots while you handle volumes. +2. Use your host tooling (`docker run --rm -v :/data busybox tar -czf - /data > snapshot.tar.gz`, or app-aware tooling such as `pg_basebackup` / `mysqldump` / `mongodump`) to capture the volume on node A. +3. Transfer the artifact to node B and restore it into the named volume there. 4. Update the Blueprint's selector to include node B; click **Apply now**. A future Volume Migration feature will automate this with app-aware backup tooling. @@ -138,6 +142,14 @@ Confirm the drift policy is `enforce` and the blueprint is enabled. Open the det Blueprints with active or drifted deployments refuse to disable; you would orphan them silently. Withdraw the deployments first, then disable. +### Where is my data after "Snapshot, then evict"? + +The named volumes managed by the stack on the target node are removed when the eviction runs `docker compose down`. The snapshot in Fleet → Snapshots holds the compose definition only; volume bytes are not included. To preserve data, capture the volume by hand before withdrawing (see *Migrating stateful data between nodes*). Bind mounts on the host filesystem are left in place by both eviction modes. + +### "Failed to capture compose snapshot before eviction" + +Sencho aborted the eviction because the pre-eviction compose snapshot could not be written. The deployment is still in place. Check the database is reachable (the snapshot lives in `fleet_snapshots`), then retry. If you accept data loss and want to evict regardless, use **Evict and destroy data** instead. + ## What's not in scope By design, Blueprints do not include: diff --git a/frontend/src/components/blueprints/BlueprintDetail.tsx b/frontend/src/components/blueprints/BlueprintDetail.tsx index f2a2a2a4..9d37081b 100644 --- a/frontend/src/components/blueprints/BlueprintDetail.tsx +++ b/frontend/src/components/blueprints/BlueprintDetail.tsx @@ -146,7 +146,10 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca try { const result = await withdrawDeployment(blueprint.id, nodeId, confirm); if (result.error) toast.error(result.error); - else toast.success(confirm === 'evict_and_destroy' ? 'Evicted and data removed' : 'Deployment withdrawn'); + else if (confirm === 'evict_and_destroy') toast.success('Evicted and data removed'); + else if (confirm === 'snapshot_then_evict' && result.snapshotId !== null) { + toast.success(`Compose snapshot #${result.snapshotId} captured. Deployment withdrawn.`); + } else toast.success('Deployment withdrawn'); await refresh(); onChanged(); } catch (err) { diff --git a/frontend/src/components/blueprints/EvictionDialog.tsx b/frontend/src/components/blueprints/EvictionDialog.tsx index b5d672a0..bd253d33 100644 --- a/frontend/src/components/blueprints/EvictionDialog.tsx +++ b/frontend/src/components/blueprints/EvictionDialog.tsx @@ -65,7 +65,7 @@ export function EvictionDialog({ Snapshot, then evict (recommended)

- Captures the compose definition into the existing fleet-snapshot store, then runs the eviction. Note: volume bytes are not shipped; that ships in a future Volume Migration feature. + Captures this stack's compose definition to Fleet → Snapshots, then runs the eviction. Volume bytes stay on this node and are removed by docker compose down. Relocate them by hand if you need them on another node.

diff --git a/frontend/src/lib/blueprintsApi.ts b/frontend/src/lib/blueprintsApi.ts index 028310fc..7d0f6ed9 100644 --- a/frontend/src/lib/blueprintsApi.ts +++ b/frontend/src/lib/blueprintsApi.ts @@ -176,7 +176,7 @@ export async function withdrawDeployment( blueprintId: number, nodeId: number, confirm: WithdrawConfirm, -): Promise<{ status: BlueprintDeploymentStatus; error: string | null; snapshotPolicy: WithdrawConfirm }> { +): Promise<{ status: BlueprintDeploymentStatus; error: string | null; snapshotPolicy: WithdrawConfirm; snapshotId: number | null }> { const res = await apiFetch(`/blueprints/${blueprintId}/withdraw/${nodeId}`, { method: 'POST', body: JSON.stringify({ confirm }),