fix(fleet): harden fleet snapshots with DRY capture, audit fixes, and design compliance (#555)

Extract duplicated snapshot capture functions from index.ts and
SchedulerService.ts into a shared module (snapshot-capture.ts). Fix
audit log route patterns that used singular 'snapshot' instead of
plural 'snapshots'. Apply design system to FleetSnapshots component
(card styling, strokeWidth, font-mono, tabular-nums, ScrollArea).
Add safePage pagination, loading toast for creation, and fix the
restore dialog race condition with a controlled AlertDialog. Add
diagnostic logging gated behind Developer Mode. Add tests for
restore endpoint, admin role enforcement, and edge cases. Update
docs with troubleshooting section and refresh screenshots.
This commit is contained in:
Anso
2026-04-13 13:26:55 -04:00
committed by GitHub
parent 620e537eda
commit 809bf76c20
10 changed files with 426 additions and 205 deletions
+180
View File
@@ -358,3 +358,183 @@ describe('Fleet snapshot lifecycle', () => {
expect(check.status).toBe(404);
});
});
// ─── Snapshot Restore Endpoint ───
describe('Fleet snapshot restore', () => {
afterEach(() => vi.restoreAllMocks());
let snapshotId: number;
beforeAll(async () => {
const { LicenseService: LS } = await import('../services/LicenseService');
vi.spyOn(LS.getInstance(), 'getTier').mockReturnValue('paid');
const res = await request(app)
.post('/api/fleet/snapshots')
.set('Authorization', authHeader)
.send({ description: 'Restore test snapshot' });
snapshotId = res.body.id;
vi.restoreAllMocks();
});
it('POST /api/fleet/snapshots/:id/restore returns 401 without auth', async () => {
const res = await request(app)
.post(`/api/fleet/snapshots/${snapshotId}/restore`)
.send({ nodeId: 1, stackName: 'test' });
expect(res.status).toBe(401);
});
it('POST /api/fleet/snapshots/:id/restore returns 403 on free tier', async () => {
mockTier('community');
const res = await request(app)
.post(`/api/fleet/snapshots/${snapshotId}/restore`)
.set('Authorization', authHeader)
.send({ nodeId: 1, stackName: 'test' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('returns 400 with missing nodeId/stackName', async () => {
mockTier('paid');
const res = await request(app)
.post(`/api/fleet/snapshots/${snapshotId}/restore`)
.set('Authorization', authHeader)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/nodeId and stackName are required/i);
});
it('returns 400 with invalid stackName (path traversal)', async () => {
mockTier('paid');
const res = await request(app)
.post(`/api/fleet/snapshots/${snapshotId}/restore`)
.set('Authorization', authHeader)
.send({ nodeId: 1, stackName: '../etc/passwd' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid stack name/i);
});
it('returns 400 for NaN snapshot ID', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/snapshots/abc/restore')
.set('Authorization', authHeader)
.send({ nodeId: 1, stackName: 'mystack' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid snapshot id/i);
});
it('returns 404 for non-existent snapshot', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/snapshots/99999/restore')
.set('Authorization', authHeader)
.send({ nodeId: 1, stackName: 'mystack' });
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/snapshot not found/i);
});
it('returns 404 when no files match the nodeId/stackName combo', async () => {
mockTier('paid');
const res = await request(app)
.post(`/api/fleet/snapshots/${snapshotId}/restore`)
.set('Authorization', authHeader)
.send({ nodeId: 999, stackName: 'nonexistent-stack' });
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/no files found/i);
});
});
// ─── Snapshot Admin Role Enforcement ───
describe('Fleet snapshot admin enforcement', () => {
afterEach(() => vi.restoreAllMocks());
let viewerHeader: string;
beforeAll(async () => {
const { DatabaseService: DS } = await import('../services/DatabaseService');
const db = DS.getInstance();
const bcrypt = await import('bcrypt');
const viewerHash = await bcrypt.hash('snapshotviewer', 1);
try {
db.addUser({ username: 'snapshotviewer', password_hash: viewerHash, role: 'viewer' });
} catch {
// User may already exist
}
const viewerToken = jwt.sign({ username: 'snapshotviewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' });
viewerHeader = `Bearer ${viewerToken}`;
});
it('POST /api/fleet/snapshots returns 403 for viewer', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/snapshots')
.set('Authorization', viewerHeader)
.send({ description: 'test' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('DELETE /api/fleet/snapshots/1 returns 403 for viewer', async () => {
mockTier('paid');
const res = await request(app)
.delete('/api/fleet/snapshots/1')
.set('Authorization', viewerHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('POST /api/fleet/snapshots/1/restore returns 403 for viewer', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/snapshots/1/restore')
.set('Authorization', viewerHeader)
.send({ nodeId: 1, stackName: 'test' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('GET /api/fleet/snapshots succeeds for viewer (read-only)', async () => {
mockTier('paid');
const res = await request(app)
.get('/api/fleet/snapshots')
.set('Authorization', viewerHeader);
expect(res.status).toBe(200);
});
});
// ─── Snapshot Edge Cases ───
describe('Fleet snapshot edge cases', () => {
afterEach(() => vi.restoreAllMocks());
it('snapshot with 0 stacks captured (empty COMPOSE_DIR)', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/snapshots')
.set('Authorization', authHeader)
.send({ description: 'Empty snapshot' });
expect(res.status).toBe(201);
expect(res.body.stack_count).toBe(0);
});
it('DELETE on already-deleted snapshot returns 404', async () => {
mockTier('paid');
const createRes = await request(app)
.post('/api/fleet/snapshots')
.set('Authorization', authHeader)
.send({ description: 'To delete twice' });
const id = createRes.body.id;
await request(app)
.delete(`/api/fleet/snapshots/${id}`)
.set('Authorization', authHeader);
mockTier('paid');
const res = await request(app)
.delete(`/api/fleet/snapshots/${id}`)
.set('Authorization', authHeader);
expect(res.status).toBe(404);
});
});
+19 -90
View File
@@ -60,6 +60,7 @@ function invalidateNodeCaches(nodeId: number): void {
import { isDebugEnabled } from './utils/debug';
import { getErrorMessage } from './utils/errors';
import { captureLocalNodeFiles, captureRemoteNodeFiles, SnapshotNodeData } from './utils/snapshot-capture';
import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog } from './utils/log-parsing';
import SelfUpdateService from './services/SelfUpdateService';
import semver from 'semver';
@@ -905,9 +906,9 @@ const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'PUT /webhooks': 'Updated webhook',
'DELETE /webhooks': 'Deleted webhook',
'PUT /settings': 'Updated settings',
'POST /fleet/snapshot': 'Created fleet backup',
'DELETE /fleet/snapshot': 'Deleted fleet backup',
'POST /fleet/snapshot/restore': 'Restored fleet backup',
'POST /fleet/snapshots': 'Created fleet backup',
'DELETE /fleet/snapshots': 'Deleted fleet backup',
'POST /fleet/snapshots/*/restore': 'Restored fleet backup',
'PUT /sso/config': 'Updated SSO configuration',
'DELETE /sso/config': 'Deleted SSO configuration',
'POST /api-tokens': 'Created API token',
@@ -2017,93 +2018,6 @@ async function fetchRemoteNodeOverview(node: Node): Promise<FleetNodeOverview> {
// ─── Fleet Snapshots (Skipper+) ───
interface SnapshotNodeData {
nodeId: number;
nodeName: string;
stacks: Array<{
stackName: string;
files: Array<{ filename: string; content: string }>;
}>;
}
async function captureLocalNodeFiles(node: Node): Promise<SnapshotNodeData> {
const fsService = FileSystemService.getInstance(node.id);
const stackNames = await fsService.getStacks();
const stacks: SnapshotNodeData['stacks'] = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeContent = await fsService.getStackContent(stackName);
files.push({ filename: 'compose.yaml', content: composeContent });
} catch (e) {
console.warn(`[Fleet Snapshot] Could not read compose file for stack "${stackName}", skipping:`, (e as Error).message);
continue;
}
try {
const envContent = await fsService.getEnvContent(stackName);
files.push({ filename: '.env', content: envContent });
} catch {
// No .env file - that's fine
}
stacks.push({ stackName, files });
}
return { nodeId: node.id, nodeName: node.name, stacks };
}
async function captureRemoteNodeFiles(node: Node): Promise<SnapshotNodeData> {
if (!node.api_url || !node.api_token) {
throw new Error('Remote node not configured');
}
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
const stacksRes = await fetch(`${baseUrl}/api/stacks`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node');
const stackNames = await stacksRes.json() as string[];
const stacks: SnapshotNodeData['stacks'] = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (composeRes.ok) {
const content = await composeRes.text();
files.push({ filename: 'compose.yaml', content });
}
} catch (e) {
console.warn(`[Fleet Snapshot] Failed to fetch remote compose for stack "${stackName}":`, (e as Error).message);
continue;
}
try {
const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (envRes.ok) {
const content = await envRes.text();
files.push({ filename: '.env', content });
}
} catch {
// No .env - skip
}
if (files.length > 0) {
stacks.push({ stackName, files });
}
}
return { nodeId: node.id, nodeName: node.name, stacks };
}
// Create fleet snapshot
app.post('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
@@ -2119,6 +2033,7 @@ app.post('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Respo
const nodes = db.getNodes();
const username = req.user?.username || 'admin';
const captureStart = Date.now();
const results = await Promise.allSettled(
nodes.map(async (node) => {
if (node.type === 'remote') {
@@ -2175,6 +2090,12 @@ app.post('/api/fleet/snapshots', authMiddleware, async (req: Request, res: Respo
}
console.log('[Fleet] Snapshot created:', capturedNodes.length, 'nodes,', totalStacks, 'stacks');
if (isDebugEnabled()) {
console.debug(`[Fleet:debug] Snapshot ${snapshotId} capture completed in ${Date.now() - captureStart}ms, ${allFiles.length} file(s) stored`);
for (const skip of skippedNodes) {
console.debug(`[Fleet:debug] Skipped node "${skip.nodeName}" (id=${skip.nodeId}): ${skip.reason}`);
}
}
const snapshot = db.getSnapshot(snapshotId);
res.status(201).json(snapshot);
} catch (error) {
@@ -2279,6 +2200,11 @@ app.post('/api/fleet/snapshots/:id/restore', authMiddleware, async (req: Request
return;
}
if (isDebugEnabled()) {
const fileNames = files.map(f => f.filename).join(', ');
console.debug(`[Fleet:debug] Restore: snapshot=${snapshotId}, node=${nodeId}, stack="${stackName}", files=[${fileNames}], redeploy=${redeploy}`);
}
const node = db.getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Target node no longer exists' });
@@ -2372,6 +2298,9 @@ app.delete('/api/fleet/snapshots/:id', authMiddleware, async (req: Request, res:
res.status(404).json({ error: 'Snapshot not found' });
return;
}
if (isDebugEnabled()) {
console.debug(`[Fleet:debug] Deleting snapshot ${id} (${snapshot.node_count} node(s), ${snapshot.stack_count} stack(s))`);
}
db.deleteSnapshot(id);
console.log('[Fleet] Snapshot deleted:', id);
res.json({ message: 'Snapshot deleted' });
+7 -78
View File
@@ -9,6 +9,7 @@ import { ImageUpdateService } from './ImageUpdateService';
import type { ImageCheckResult } from './ImageUpdateService';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot-capture';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
@@ -253,9 +254,9 @@ export class SchedulerService {
const results = await Promise.allSettled(
nodes.map(async (node) => {
if (node.type === 'remote') {
return this.captureRemoteNodeFiles(node);
return captureRemoteNodeFiles(node);
}
return this.captureLocalNodeFiles(node);
return captureLocalNodeFiles(node);
})
);
@@ -305,85 +306,13 @@ export class SchedulerService {
db.insertSnapshotFiles(snapshotId, allFiles);
}
if (isDebugEnabled()) {
console.debug(`[SchedulerService:debug] Snapshot task ${task.id}: captured ${capturedNodes.length} node(s), ${totalStacks} stack(s), ${allFiles.length} file(s), skipped ${skippedNodes.length}`);
}
return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNodes.length > 0 ? `, ${skippedNodes.length} skipped` : ''})`;
}
private async captureLocalNodeFiles(node: { id: number; name: string }) {
const fsService = FileSystemService.getInstance(node.id);
const stackNames = await fsService.getStacks();
const stacks: Array<{ stackName: string; files: Array<{ filename: string; content: string }> }> = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeContent = await fsService.getStackContent(stackName);
files.push({ filename: 'compose.yaml', content: composeContent });
} catch {
continue;
}
try {
const envContent = await fsService.getEnvContent(stackName);
files.push({ filename: '.env', content: envContent });
} catch {
// No .env file
}
stacks.push({ stackName, files });
}
return { nodeId: node.id, nodeName: node.name, stacks };
}
private async captureRemoteNodeFiles(node: { id: number; name: string; api_url?: string; api_token?: string }) {
if (!node.api_url || !node.api_token) {
throw new Error('Remote node not configured');
}
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
const stacksRes = await fetch(`${baseUrl}/api/stacks`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node');
const stackNames = await stacksRes.json() as string[];
const stacks: Array<{ stackName: string; files: Array<{ filename: string; content: string }> }> = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (composeRes.ok) {
const content = await composeRes.text();
files.push({ filename: 'compose.yaml', content });
}
} catch {
continue;
}
try {
const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (envRes.ok) {
const content = await envRes.text();
files.push({ filename: '.env', content });
}
} catch {
// No .env
}
if (files.length > 0) {
stacks.push({ stackName, files });
}
}
return { nodeId: node.id, nodeName: node.name, stacks };
}
private async executePrune(task: ScheduledTask): Promise<string> {
const nodeId = task.node_id ?? NodeRegistry.getInstance().getDefaultNodeId();
if (task.node_id == null && isDebugEnabled()) {
+123
View File
@@ -0,0 +1,123 @@
/**
* Shared snapshot capture functions used by both the REST API (index.ts)
* and the SchedulerService for fleet-wide snapshot operations.
*/
import { FileSystemService } from '../services/FileSystemService';
import { isDebugEnabled } from './debug';
export interface SnapshotNodeData {
nodeId: number;
nodeName: string;
stacks: Array<{
stackName: string;
files: Array<{ filename: string; content: string }>;
}>;
}
/** Minimal node shape accepted by capture functions. */
export interface CaptureNode {
id: number;
name: string;
api_url?: string;
api_token?: string;
}
/**
* Read compose.yaml and .env files for every stack on a local node.
* Stacks whose compose file cannot be read are silently skipped.
*/
export async function captureLocalNodeFiles(node: CaptureNode): Promise<SnapshotNodeData> {
const start = Date.now();
const fsService = FileSystemService.getInstance(node.id);
const stackNames = await fsService.getStacks();
const stacks: SnapshotNodeData['stacks'] = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeContent = await fsService.getStackContent(stackName);
files.push({ filename: 'compose.yaml', content: composeContent });
} catch (e) {
console.warn(`[Fleet Snapshot] Could not read compose file for stack "${stackName}", skipping:`, (e as Error).message);
continue;
}
try {
const envContent = await fsService.getEnvContent(stackName);
files.push({ filename: '.env', content: envContent });
} catch {
// No .env file - that's fine
}
stacks.push({ stackName, files });
}
if (isDebugEnabled()) {
const fileCount = stacks.reduce((sum, s) => sum + s.files.length, 0);
console.debug(`[Fleet:debug] Local capture "${node.name}": ${stacks.length} stack(s), ${fileCount} file(s) in ${Date.now() - start}ms`);
}
return { nodeId: node.id, nodeName: node.name, stacks };
}
/**
* Fetch compose.yaml and .env files for every stack on a remote node
* via the Distributed API proxy. Stacks whose compose file cannot be
* fetched are silently skipped.
*/
export async function captureRemoteNodeFiles(node: CaptureNode): Promise<SnapshotNodeData> {
if (!node.api_url || !node.api_token) {
throw new Error('Remote node not configured');
}
const start = Date.now();
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
const stacksRes = await fetch(`${baseUrl}/api/stacks`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node');
const stackNames = await stacksRes.json() as string[];
const stacks: SnapshotNodeData['stacks'] = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
try {
const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (composeRes.ok) {
const content = await composeRes.text();
files.push({ filename: 'compose.yaml', content });
}
} catch (e) {
console.warn(`[Fleet Snapshot] Failed to fetch remote compose for stack "${stackName}":`, (e as Error).message);
continue;
}
try {
const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (envRes.ok) {
const content = await envRes.text();
files.push({ filename: '.env', content });
}
} catch {
// No .env - skip
}
if (files.length > 0) {
stacks.push({ stackName, files });
}
}
if (isDebugEnabled()) {
const fileCount = stacks.reduce((sum, s) => sum + s.files.length, 0);
console.debug(`[Fleet:debug] Remote capture "${node.name}": ${stacks.length} stack(s), ${fileCount} file(s) in ${Date.now() - start}ms`);
}
return { nodeId: node.id, nodeName: node.name, stacks };
}