mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-05 05:39:04 +00:00
Initial commit
This commit is contained in:
463
routes/api/batch/+server.ts
Normal file
463
routes/api/batch/+server.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user