Files
dockhand/routes/api/batch/+server.ts
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

464 lines
11 KiB
TypeScript

import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
startContainer,
stopContainer,
restartContainer,
pauseContainer,
unpauseContainer,
removeContainer,
inspectContainer,
listContainers,
removeImage,
removeVolume,
removeNetwork
} from '$lib/server/docker';
import {
startStack,
stopStack,
restartStack,
downStack,
removeStack
} from '$lib/server/stacks';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
// SSE Event types
export type BatchEventType = 'start' | 'progress' | 'complete' | 'error';
export type ItemStatus = 'pending' | 'processing' | 'success' | 'error';
export interface BatchStartEvent {
type: 'start';
total: number;
}
export interface BatchProgressEvent {
type: 'progress';
id: string;
name: string;
status: ItemStatus;
message?: string;
error?: string;
current: number;
total: number;
}
export interface BatchCompleteEvent {
type: 'complete';
summary: {
total: number;
success: number;
failed: number;
};
}
export interface BatchErrorEvent {
type: 'error';
error: string;
}
export type BatchEvent = BatchStartEvent | BatchProgressEvent | BatchCompleteEvent | BatchErrorEvent;
// Supported operations per entity type
const ENTITY_OPERATIONS: Record<string, string[]> = {
containers: ['start', 'stop', 'restart', 'pause', 'unpause', 'remove'],
images: ['remove'],
volumes: ['remove'],
networks: ['remove'],
stacks: ['start', 'stop', 'restart', 'down', 'remove']
};
// Permission mapping for entity operations
const PERMISSION_MAP: Record<string, Record<string, string>> = {
containers: {
start: 'start',
stop: 'stop',
restart: 'restart',
pause: 'stop',
unpause: 'start',
remove: 'remove'
},
images: { remove: 'remove' },
volumes: { remove: 'remove' },
networks: { remove: 'remove' },
stacks: {
start: 'start',
stop: 'stop',
restart: 'restart',
down: 'stop',
remove: 'remove'
}
};
interface BatchRequest {
operation: string;
entityType: string;
items: Array<{ id: string; name: string }>;
options?: {
force?: boolean;
removeVolumes?: boolean;
};
}
// Concurrent execution helper with controlled parallelism
async function processWithConcurrency<T>(
items: T[],
concurrency: number,
processor: (item: T, index: number) => Promise<void>,
signal: AbortSignal
): Promise<void> {
let currentIndex = 0;
const total = items.length;
async function processNext(): Promise<void> {
while (currentIndex < total) {
if (signal.aborted) return;
const index = currentIndex++;
await processor(items[index], index);
}
}
// Start 'concurrency' number of workers
const workers = Array(Math.min(concurrency, total))
.fill(null)
.map(() => processNext());
await Promise.all(workers);
}
/**
* Unified batch operations endpoint with SSE streaming.
* Handles bulk operations for containers, images, volumes, networks, and stacks.
*/
export const POST: RequestHandler = async ({ url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Parse request body
let body: BatchRequest;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { operation, entityType, items, options = {} } = body;
// Validate entity type
if (!ENTITY_OPERATIONS[entityType]) {
return json(
{ error: `Invalid entity type: ${entityType}. Supported: ${Object.keys(ENTITY_OPERATIONS).join(', ')}` },
{ status: 400 }
);
}
// Validate operation for entity type
if (!ENTITY_OPERATIONS[entityType].includes(operation)) {
return json(
{ error: `Invalid operation '${operation}' for ${entityType}. Supported: ${ENTITY_OPERATIONS[entityType].join(', ')}` },
{ status: 400 }
);
}
// Validate items
if (!items || !Array.isArray(items) || items.length === 0) {
return json({ error: 'items array is required and must not be empty' }, { status: 400 });
}
// Permission check
const permissionAction = PERMISSION_MAP[entityType][operation];
if (auth.authEnabled && !(await auth.can(entityType as any, permissionAction, envIdNum))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// Check if audit is needed (enterprise only)
const needsAudit = auth.isEnterprise;
// Create abort controller for cancellation
const abortController = new AbortController();
const encoder = new TextEncoder();
let controllerClosed = false;
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
async start(controller) {
const safeEnqueue = (data: BatchEvent) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
controllerClosed = true;
abortController.abort();
}
}
};
// Send SSE keepalive comments every 5s
keepaliveInterval = setInterval(() => {
if (controllerClosed) return;
try {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
} catch {
controllerClosed = true;
abortController.abort();
}
}, 5000);
let successCount = 0;
let failCount = 0;
// Send start event
safeEnqueue({
type: 'start',
total: items.length
});
// Process items with concurrency of 3
await processWithConcurrency(
items,
3,
async (item, index) => {
if (abortController.signal.aborted) return;
const { id, name } = item;
// Send processing status
safeEnqueue({
type: 'progress',
id,
name,
status: 'processing',
current: index + 1,
total: items.length
});
try {
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
safeEnqueue({
type: 'progress',
id,
name,
status: 'success',
current: index + 1,
total: items.length
});
successCount++;
} catch (error: any) {
safeEnqueue({
type: 'progress',
id,
name,
status: 'error',
error: error.message || 'Unknown error',
current: index + 1,
total: items.length
});
failCount++;
}
},
abortController.signal
);
// Send complete event
safeEnqueue({
type: 'complete',
summary: {
total: items.length,
success: successCount,
failed: failCount
}
});
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
controller.close();
},
cancel() {
controllerClosed = true;
abortController.abort();
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};
/**
* Execute a single operation on an entity.
* Centralized operation execution to keep code DRY.
*/
async function executeOperation(
entityType: string,
operation: string,
id: string,
name: string,
envIdNum: number | undefined,
options: { force?: boolean; removeVolumes?: boolean },
needsAudit: boolean
): Promise<void> {
switch (entityType) {
case 'containers':
await executeContainerOperation(operation, id, name, envIdNum, options, needsAudit);
break;
case 'images':
await executeImageOperation(operation, id, envIdNum, options);
break;
case 'volumes':
await executeVolumeOperation(operation, id, envIdNum, options);
break;
case 'networks':
await executeNetworkOperation(operation, id, envIdNum);
break;
case 'stacks':
await executeStackOperation(operation, id, envIdNum, options);
break;
default:
throw new Error(`Unsupported entity type: ${entityType}`);
}
}
async function executeContainerOperation(
operation: string,
id: string,
name: string,
envIdNum: number | undefined,
options: { force?: boolean },
needsAudit: boolean
): Promise<void> {
switch (operation) {
case 'start':
await startContainer(id, envIdNum);
break;
case 'stop':
await stopContainer(id, envIdNum);
break;
case 'restart':
await restartContainer(id, envIdNum);
break;
case 'pause':
await pauseContainer(id, envIdNum);
break;
case 'unpause':
await unpauseContainer(id, envIdNum);
break;
case 'remove':
// In free edition, skip inspect (no audit needed)
// In enterprise, we might want to audit but for batch ops we skip for performance
await removeContainer(id, options.force ?? true, envIdNum);
// Clean up auto-update schedule if exists
try {
const setting = await getAutoUpdateSetting(name, envIdNum);
if (setting) {
unregisterSchedule(setting.id, 'container_update');
await deleteAutoUpdateSchedule(name, envIdNum);
}
} catch {
// Ignore cleanup errors
}
break;
default:
throw new Error(`Unsupported container operation: ${operation}`);
}
}
async function executeImageOperation(
operation: string,
id: string,
envIdNum: number | undefined,
options: { force?: boolean }
): Promise<void> {
switch (operation) {
case 'remove':
await removeImage(id, options.force ?? false, envIdNum);
break;
default:
throw new Error(`Unsupported image operation: ${operation}`);
}
}
async function executeVolumeOperation(
operation: string,
name: string,
envIdNum: number | undefined,
options: { force?: boolean }
): Promise<void> {
switch (operation) {
case 'remove':
await removeVolume(name, options.force ?? false, envIdNum);
break;
default:
throw new Error(`Unsupported volume operation: ${operation}`);
}
}
async function executeNetworkOperation(
operation: string,
id: string,
envIdNum: number | undefined
): Promise<void> {
switch (operation) {
case 'remove':
await removeNetwork(id, envIdNum);
break;
default:
throw new Error(`Unsupported network operation: ${operation}`);
}
}
async function executeStackOperation(
operation: string,
name: string,
envIdNum: number | undefined,
options: { removeVolumes?: boolean; force?: boolean }
): Promise<void> {
switch (operation) {
case 'start': {
const result = await startStack(name, envIdNum);
if (!result.success) throw new Error(result.error || 'Failed to start stack');
break;
}
case 'stop': {
const result = await stopStack(name, envIdNum);
if (!result.success) throw new Error(result.error || 'Failed to stop stack');
break;
}
case 'restart': {
const result = await restartStack(name, envIdNum);
if (!result.success) throw new Error(result.error || 'Failed to restart stack');
break;
}
case 'down': {
const result = await downStack(name, envIdNum, options.removeVolumes ?? false);
if (!result.success) throw new Error(result.error || 'Failed to down stack');
break;
}
case 'remove': {
const result = await removeStack(name, envIdNum, options.force ?? false);
if (!result.success) throw new Error(result.error || 'Failed to remove stack');
break;
}
default:
throw new Error(`Unsupported stack operation: ${operation}`);
}
}