feat(blueprints): capture compose snapshot before stateful eviction (#957)

Wire the snapshot_then_evict withdraw mode to actually persist the
blueprint's compose YAML to fleet_snapshots before running the
eviction. The mode previously recorded intent only. Capture failure
aborts the eviction with HTTP 500 rather than silently falling
through to a destructive withdraw.

Volume bytes remain out of scope: the snapshot holds the compose
definition only. UI copy and the Blueprints docs (Withdraw note,
Migrating stateful data section, two new Troubleshooting entries)
clarify that operators must move volumes by hand if they need the
data on another node.

Adds 9 route-level tests covering the success path, snapshot DB
write failure, orphan-row cleanup when insertSnapshotFiles fails,
empty compose_content, evict_and_destroy unchanged, stateless
unchanged, evict_blocked gate, omitted confirm field, and bad
confirm value.
This commit is contained in:
Anso
2026-05-06 21:58:45 -04:00
committed by GitHub
parent aa00dc2b89
commit 7fe90d9f3a
6 changed files with 357 additions and 11 deletions
@@ -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);
});
});
+46 -3
View File
@@ -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') });
+17 -5
View File
@@ -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).
<Note>
**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).
</Note>
### 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 <volume>:/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 <volume>:/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:
@@ -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) {
@@ -65,7 +65,7 @@ export function EvictionDialog({
Snapshot, then evict (recommended)
</div>
<p className="text-xs text-stat-subtitle mt-1.5 leading-relaxed">
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.
</p>
</button>
<div className="space-y-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
+1 -1
View File
@@ -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 }),