proper src structure, dockerfile, entrypoint
This commit is contained in:
1 parent
e5828c7d31
commit
73c9f580a1
556 files changed
+1390
No files matched your search
@@ -0,0 +1,123 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const all = url.searchParams.get('all') !== 'false';
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', 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 });
|
||||
}
|
||||
|
||||
// Early return if no environments configured (fresh install)
|
||||
if (!await hasEnvironments()) {
|
||||
return json([]);
|
||||
}
|
||||
|
||||
// Early return if no environment specified
|
||||
if (!envIdNum) {
|
||||
return json([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const containers = await listContainers(all, envIdNum);
|
||||
return json(containers);
|
||||
} catch (error: any) {
|
||||
// Return 404 for missing environment so frontend can clear stale localStorage
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error listing containers:', error);
|
||||
// Return empty array instead of error to allow UI to load
|
||||
return json([]);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'create', 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 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { startAfterCreate, ...options } = body;
|
||||
|
||||
// Check if image needs to be pulled
|
||||
try {
|
||||
console.log(`Attempting to create container with image: ${options.image}`);
|
||||
const container = await createContainer(options, envIdNum);
|
||||
|
||||
// Start the container if requested
|
||||
if (startAfterCreate) {
|
||||
await container.start();
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'create', container.id, options.name, envIdNum, { image: options.image });
|
||||
|
||||
return json({ success: true, id: container.id });
|
||||
} catch (createError: any) {
|
||||
// If error is due to missing image, try to pull it first
|
||||
if (createError.statusCode === 404 && createError.json?.message?.includes('No such image')) {
|
||||
console.log(`Image ${options.image} not found locally. Pulling...`);
|
||||
|
||||
try {
|
||||
// Pull the image
|
||||
await pullImage(options.image, undefined, envIdNum);
|
||||
console.log(`Successfully pulled image: ${options.image}`);
|
||||
|
||||
// Retry creating the container
|
||||
const container = await createContainer(options, envIdNum);
|
||||
|
||||
// Start the container if requested
|
||||
if (startAfterCreate) {
|
||||
await container.start();
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'create', container.id, options.name, envIdNum, { image: options.image, imagePulled: true });
|
||||
|
||||
return json({ success: true, id: container.id, imagePulled: true });
|
||||
} catch (pullError) {
|
||||
console.error('Error pulling image:', pullError);
|
||||
return json({
|
||||
error: 'Failed to pull image',
|
||||
details: `Could not pull image ${options.image}: ${String(pullError)}`
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a different error, rethrow it
|
||||
throw createError;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Container] Create failed: ${message}`);
|
||||
return json({ error: 'Failed to create container', details: message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
inspectContainer,
|
||||
removeContainer,
|
||||
getContainerLogs
|
||||
} from '$lib/server/docker';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', 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 });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
return json(details);
|
||||
} catch (error) {
|
||||
console.error('Error inspecting container:', error);
|
||||
return json({ error: 'Failed to inspect container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const force = url.searchParams.get('force') === 'true';
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'remove', 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 });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// Get container name before deletion for audit
|
||||
let containerName = params.id;
|
||||
try {
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
containerName = details.Name?.replace(/^\//, '') || params.id;
|
||||
} catch {
|
||||
// Container might not exist or other error, use ID
|
||||
}
|
||||
|
||||
await removeContainer(params.id, force, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'delete', params.id, containerName, envIdNum, { force });
|
||||
|
||||
// Clean up auto-update schedule if exists
|
||||
try {
|
||||
// Get the schedule ID before deleting
|
||||
const setting = await getAutoUpdateSetting(containerName, envIdNum);
|
||||
if (setting) {
|
||||
// Unregister from croner
|
||||
unregisterSchedule(setting.id, 'container_update');
|
||||
// Delete from database
|
||||
await deleteAutoUpdateSchedule(containerName, envIdNum);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup auto-update schedule:', error);
|
||||
// Don't fail the deletion if schedule cleanup fails
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing container:', error);
|
||||
return json({ error: 'Failed to remove container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Container Exec API
|
||||
*
|
||||
* POST: Creates an exec instance for terminal attachment
|
||||
* Returns exec ID that can be used for WebSocket connection
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const containerId = params.id;
|
||||
const envIdParam = url.searchParams.get('envId');
|
||||
const envId = envIdParam ? parseInt(envIdParam, 10) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (!await auth.can('containers', 'exec', envId)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const shell = body.shell || '/bin/sh';
|
||||
const user = body.user || 'root';
|
||||
|
||||
// Create exec instance
|
||||
const exec = await createExec({
|
||||
containerId,
|
||||
cmd: [shell],
|
||||
user,
|
||||
envId
|
||||
});
|
||||
|
||||
// Get connection info for the frontend
|
||||
const connectionInfo = await getDockerConnectionInfo(envId);
|
||||
|
||||
return json({
|
||||
execId: exec.Id,
|
||||
connectionInfo: {
|
||||
type: connectionInfo.type,
|
||||
host: connectionInfo.host,
|
||||
port: connectionInfo.port
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create exec:', error);
|
||||
return json(
|
||||
{ error: error.message || 'Failed to create exec instance' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listContainerDirectory } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path') || '/';
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
const simpleLs = url.searchParams.get('simpleLs') === 'true';
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listContainerDirectory(
|
||||
params.id,
|
||||
path,
|
||||
envIdNum,
|
||||
simpleLs
|
||||
);
|
||||
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Error listing container directory:', error);
|
||||
return json({ error: error.message || 'Failed to list directory' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { chmodContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { path, mode, recursive } = body;
|
||||
|
||||
if (!path || typeof path !== 'string') {
|
||||
return json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!mode || typeof mode !== 'string') {
|
||||
return json({ error: 'Mode is required (e.g., "755" or "u+x")' }, { status: 400 });
|
||||
}
|
||||
|
||||
await chmodContainerPath(params.id, path, mode, recursive === true, envIdNum);
|
||||
|
||||
return json({ success: true, path, mode, recursive: recursive === true });
|
||||
} catch (error: any) {
|
||||
console.error('Error changing permissions:', error);
|
||||
const msg = error.message || String(error);
|
||||
|
||||
if (msg.includes('Permission denied')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('No such file or directory')) {
|
||||
return json({ error: 'Path not found' }, { status: 404 });
|
||||
}
|
||||
if (msg.includes('Invalid chmod mode')) {
|
||||
return json({ error: msg }, { status: 400 });
|
||||
}
|
||||
if (msg.includes('Read-only file system')) {
|
||||
return json({ error: 'File system is read-only' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('Operation not permitted')) {
|
||||
return json({ error: 'Operation not permitted' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('container is not running')) {
|
||||
return json({ error: 'Container is not running' }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: `Failed to change permissions: ${msg}` }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { readContainerFile, writeContainerFile } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Max file size for reading (1MB)
|
||||
const MAX_FILE_SIZE = 1024 * 1024;
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!path) {
|
||||
return json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const content = await readContainerFile(
|
||||
params.id,
|
||||
path,
|
||||
envIdNum
|
||||
);
|
||||
|
||||
// Check if content is too large
|
||||
if (content.length > MAX_FILE_SIZE) {
|
||||
return json({ error: 'File is too large to edit (max 1MB)' }, { status: 413 });
|
||||
}
|
||||
|
||||
return json({ content, path });
|
||||
} catch (error: any) {
|
||||
console.error('Error reading container file:', error);
|
||||
const msg = error.message || String(error);
|
||||
|
||||
if (msg.includes('No such file or directory')) {
|
||||
return json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
if (msg.includes('Permission denied')) {
|
||||
return json({ error: 'Permission denied to read this file' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('Is a directory')) {
|
||||
return json({ error: 'Cannot read a directory' }, { status: 400 });
|
||||
}
|
||||
if (msg.includes('container is not running')) {
|
||||
return json({ error: 'Container is not running' }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: `Failed to read file: ${msg}` }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!path) {
|
||||
return json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
if (typeof body.content !== 'string') {
|
||||
return json({ error: 'Content is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check content size
|
||||
if (body.content.length > MAX_FILE_SIZE) {
|
||||
return json({ error: 'Content is too large (max 1MB)' }, { status: 413 });
|
||||
}
|
||||
|
||||
await writeContainerFile(
|
||||
params.id,
|
||||
path,
|
||||
body.content,
|
||||
envIdNum
|
||||
);
|
||||
|
||||
return json({ success: true, path });
|
||||
} catch (error: any) {
|
||||
console.error('Error writing container file:', error);
|
||||
const msg = error.message || String(error);
|
||||
|
||||
if (msg.includes('Permission denied')) {
|
||||
return json({ error: 'Permission denied to write this file' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('No such file or directory')) {
|
||||
return json({ error: 'Directory not found' }, { status: 404 });
|
||||
}
|
||||
if (msg.includes('Read-only file system')) {
|
||||
return json({ error: 'File system is read-only' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('No space left on device')) {
|
||||
return json({ error: 'No space left on device' }, { status: 507 });
|
||||
}
|
||||
if (msg.includes('container is not running')) {
|
||||
return json({ error: 'Container is not running' }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: `Failed to write file: ${msg}` }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createContainerFile, createContainerDirectory } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { path, type } = body;
|
||||
|
||||
if (!path || typeof path !== 'string') {
|
||||
return json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (type !== 'file' && type !== 'directory') {
|
||||
return json({ error: 'Type must be "file" or "directory"' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (type === 'file') {
|
||||
await createContainerFile(params.id, path, envIdNum);
|
||||
} else {
|
||||
await createContainerDirectory(params.id, path, envIdNum);
|
||||
}
|
||||
|
||||
return json({ success: true, path, type });
|
||||
} catch (error: any) {
|
||||
console.error('Error creating path:', error);
|
||||
const msg = error.message || String(error);
|
||||
|
||||
if (msg.includes('Permission denied')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('File exists')) {
|
||||
return json({ error: 'Path already exists' }, { status: 409 });
|
||||
}
|
||||
if (msg.includes('No such file or directory')) {
|
||||
return json({ error: 'Parent directory not found' }, { status: 404 });
|
||||
}
|
||||
if (msg.includes('container is not running')) {
|
||||
return json({ error: 'Container is not running' }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: `Failed to create: ${msg}` }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { deleteContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!path) {
|
||||
return json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await deleteContainerPath(params.id, path, envIdNum);
|
||||
|
||||
return json({ success: true, path });
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting path:', error);
|
||||
const msg = error.message || String(error);
|
||||
|
||||
if (msg.includes('Permission denied')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('No such file or directory')) {
|
||||
return json({ error: 'Path not found' }, { status: 404 });
|
||||
}
|
||||
if (msg.includes('Cannot delete critical')) {
|
||||
return json({ error: msg }, { status: 400 });
|
||||
}
|
||||
if (msg.includes('Read-only file system')) {
|
||||
return json({ error: 'File system is read-only' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('Directory not empty')) {
|
||||
return json({ error: 'Directory is not empty' }, { status: 400 });
|
||||
}
|
||||
if (msg.includes('container is not running')) {
|
||||
return json({ error: 'Container is not running' }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: `Failed to delete: ${msg}` }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return new Response(JSON.stringify({ error: 'Permission denied' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return new Response(JSON.stringify({ error: 'Path is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get format from query parameter (defaults to tar)
|
||||
const format = url.searchParams.get('format') || 'tar';
|
||||
|
||||
// Get stat info to determine filename
|
||||
let filename: string;
|
||||
try {
|
||||
const stat = await statContainerPath(params.id, path, envIdNum);
|
||||
filename = stat.name || path.split('/').pop() || 'download';
|
||||
} catch {
|
||||
filename = path.split('/').pop() || 'download';
|
||||
}
|
||||
|
||||
// Get the archive from Docker
|
||||
const response = await getContainerArchive(
|
||||
params.id,
|
||||
path,
|
||||
envIdNum
|
||||
);
|
||||
|
||||
// Prepare response based on format
|
||||
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
|
||||
let contentType = 'application/x-tar';
|
||||
let extension = '.tar';
|
||||
|
||||
if (format === 'tar.gz') {
|
||||
// Compress with gzip using Bun's native implementation
|
||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||
body = Bun.gzipSync(tarData);
|
||||
contentType = 'application/gzip';
|
||||
extension = '.tar.gz';
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename="${filename}${extension}"`
|
||||
};
|
||||
|
||||
// Set content length for compressed data
|
||||
if (body instanceof Uint8Array) {
|
||||
headers['Content-Length'] = body.length.toString();
|
||||
} else {
|
||||
// Pass through content length for streaming tar
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength) {
|
||||
headers['Content-Length'] = contentLength;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(body, { headers });
|
||||
} catch (error: any) {
|
||||
console.error('Error downloading container file:', error);
|
||||
|
||||
if (error.message?.includes('No such file or directory')) {
|
||||
return new Response(JSON.stringify({ error: 'File not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
if (error.message?.includes('Permission denied')) {
|
||||
return new Response(JSON.stringify({ error: 'Permission denied to access this path' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: 'Failed to download file' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { renameContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { oldPath, newPath } = body;
|
||||
|
||||
if (!oldPath || typeof oldPath !== 'string') {
|
||||
return json({ error: 'Old path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!newPath || typeof newPath !== 'string') {
|
||||
return json({ error: 'New path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await renameContainerPath(params.id, oldPath, newPath, envIdNum);
|
||||
|
||||
return json({ success: true, oldPath, newPath });
|
||||
} catch (error: any) {
|
||||
console.error('Error renaming path:', error);
|
||||
const msg = error.message || String(error);
|
||||
|
||||
if (msg.includes('Permission denied')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('No such file or directory')) {
|
||||
return json({ error: 'Source path not found' }, { status: 404 });
|
||||
}
|
||||
if (msg.includes('File exists') || msg.includes('Directory not empty')) {
|
||||
return json({ error: 'Destination already exists' }, { status: 409 });
|
||||
}
|
||||
if (msg.includes('Read-only file system')) {
|
||||
return json({ error: 'File system is read-only' }, { status: 403 });
|
||||
}
|
||||
if (msg.includes('container is not running')) {
|
||||
return json({ error: 'Container is not running' }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: `Failed to rename: ${msg}` }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { putContainerArchive } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Create a simple tar archive from a single file
|
||||
* TAR format: 512-byte header followed by file content padded to 512 bytes
|
||||
*/
|
||||
function createTarArchive(filename: string, content: Uint8Array): Uint8Array {
|
||||
// TAR header is 512 bytes
|
||||
const header = new Uint8Array(512);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// File name (100 bytes)
|
||||
const nameBytes = encoder.encode(filename.slice(0, 99));
|
||||
header.set(nameBytes, 0);
|
||||
|
||||
// File mode (8 bytes) - 0644
|
||||
header.set(encoder.encode('0000644\0'), 100);
|
||||
|
||||
// Owner UID (8 bytes)
|
||||
header.set(encoder.encode('0000000\0'), 108);
|
||||
|
||||
// Owner GID (8 bytes)
|
||||
header.set(encoder.encode('0000000\0'), 116);
|
||||
|
||||
// File size in octal (12 bytes)
|
||||
const sizeOctal = content.length.toString(8).padStart(11, '0');
|
||||
header.set(encoder.encode(sizeOctal + '\0'), 124);
|
||||
|
||||
// Modification time (12 bytes) - current time in octal
|
||||
const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0');
|
||||
header.set(encoder.encode(mtime + '\0'), 136);
|
||||
|
||||
// Checksum placeholder (8 spaces initially)
|
||||
header.set(encoder.encode(' '), 148);
|
||||
|
||||
// Type flag - '0' for regular file
|
||||
header[156] = 48; // '0'
|
||||
|
||||
// Link name (100 bytes) - empty
|
||||
// Magic (6 bytes) - 'ustar\0'
|
||||
header.set(encoder.encode('ustar\0'), 257);
|
||||
|
||||
// Version (2 bytes) - '00'
|
||||
header.set(encoder.encode('00'), 263);
|
||||
|
||||
// Owner name (32 bytes)
|
||||
header.set(encoder.encode('root'), 265);
|
||||
|
||||
// Group name (32 bytes)
|
||||
header.set(encoder.encode('root'), 297);
|
||||
|
||||
// Calculate checksum
|
||||
let checksum = 0;
|
||||
for (let i = 0; i < 512; i++) {
|
||||
checksum += header[i];
|
||||
}
|
||||
const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 ';
|
||||
header.set(encoder.encode(checksumOctal), 148);
|
||||
|
||||
// Calculate padding to 512-byte boundary
|
||||
const paddingSize = (512 - (content.length % 512)) % 512;
|
||||
const padding = new Uint8Array(paddingSize);
|
||||
|
||||
// End of archive marker (two 512-byte zero blocks)
|
||||
const endMarker = new Uint8Array(1024);
|
||||
|
||||
// Combine all parts
|
||||
const totalSize = header.length + content.length + paddingSize + endMarker.length;
|
||||
const tar = new Uint8Array(totalSize);
|
||||
|
||||
let offset = 0;
|
||||
tar.set(header, offset);
|
||||
offset += header.length;
|
||||
tar.set(content, offset);
|
||||
offset += content.length;
|
||||
tar.set(padding, offset);
|
||||
offset += paddingSize;
|
||||
tar.set(endMarker, offset);
|
||||
|
||||
return tar;
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return json({ error: 'Target path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const files = formData.getAll('files') as File[];
|
||||
|
||||
if (files.length === 0) {
|
||||
return json({ error: 'No files provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// For simplicity, we'll upload files one at a time
|
||||
// A more sophisticated implementation could pack multiple files into one tar
|
||||
const uploaded: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = new Uint8Array(await file.arrayBuffer());
|
||||
const tar = createTarArchive(file.name, content);
|
||||
|
||||
await putContainerArchive(
|
||||
params.id,
|
||||
path,
|
||||
tar,
|
||||
envId ? parseInt(envId) : undefined
|
||||
);
|
||||
|
||||
uploaded.push(file.name);
|
||||
} catch (err: any) {
|
||||
errors.push(`${file.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0 && uploaded.length === 0) {
|
||||
return json({ error: 'Failed to upload files', details: errors }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
uploaded,
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading to container:', error);
|
||||
|
||||
if (error.message?.includes('Permission denied')) {
|
||||
return json({ error: 'Permission denied to write to this path' }, { status: 403 });
|
||||
}
|
||||
if (error.message?.includes('No such file or directory')) {
|
||||
return json({ error: 'Target directory not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({ error: 'Failed to upload files' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const containerData = await inspectContainer(params.id, envIdNum);
|
||||
return json(containerData);
|
||||
} catch (error) {
|
||||
console.error('Failed to inspect container:', error);
|
||||
return json({ error: 'Failed to inspect container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getContainerLogs } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const tail = parseInt(url.searchParams.get('tail') || '100');
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const logs = await getContainerLogs(params.id, tail, envIdNum);
|
||||
return json({ logs });
|
||||
} catch (error: any) {
|
||||
console.error('Error getting container logs:', error?.message || error, error?.stack);
|
||||
return json({ error: 'Failed to get container logs', details: error?.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,452 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
// Detect Docker socket path
|
||||
function detectDockerSocket(): string {
|
||||
if (process.env.DOCKER_SOCKET && existsSync(process.env.DOCKER_SOCKET)) {
|
||||
return process.env.DOCKER_SOCKET;
|
||||
}
|
||||
if (process.env.DOCKER_HOST?.startsWith('unix://')) {
|
||||
const socketPath = process.env.DOCKER_HOST.replace('unix://', '');
|
||||
if (existsSync(socketPath)) return socketPath;
|
||||
}
|
||||
const possibleSockets = [
|
||||
'/var/run/docker.sock',
|
||||
`${homedir()}/.docker/run/docker.sock`,
|
||||
`${homedir()}/.orbstack/run/docker.sock`,
|
||||
'/run/docker.sock'
|
||||
];
|
||||
for (const socket of possibleSockets) {
|
||||
if (existsSync(socket)) return socket;
|
||||
}
|
||||
return '/var/run/docker.sock';
|
||||
}
|
||||
|
||||
const socketPath = detectDockerSocket();
|
||||
|
||||
interface DockerClientConfig {
|
||||
type: 'socket' | 'http' | 'https' | 'hawser-edge';
|
||||
socketPath?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
ca?: string;
|
||||
cert?: string;
|
||||
key?: string;
|
||||
hawserToken?: string;
|
||||
environmentId?: number;
|
||||
}
|
||||
|
||||
async function getDockerConfig(envId?: number | null): Promise<DockerClientConfig | null> {
|
||||
if (!envId) {
|
||||
return null;
|
||||
}
|
||||
const env = await getEnvironment(envId);
|
||||
if (!env) {
|
||||
return null;
|
||||
}
|
||||
if (env.connectionType === 'socket' || !env.connectionType) {
|
||||
return { type: 'socket', socketPath: env.socketPath || socketPath };
|
||||
}
|
||||
if (env.connectionType === 'hawser-edge') {
|
||||
return { type: 'hawser-edge', environmentId: envId };
|
||||
}
|
||||
const protocol = (env.protocol as 'http' | 'https') || 'http';
|
||||
return {
|
||||
type: protocol,
|
||||
host: env.host || 'localhost',
|
||||
port: env.port || 2375,
|
||||
ca: env.tlsCa || undefined,
|
||||
cert: env.tlsCert || undefined,
|
||||
key: env.tlsKey || undefined,
|
||||
hawserToken: env.connectionType === 'hawser-standard' ? env.hawserToken || undefined : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Demultiplex Docker stream frame - returns payload and stream type
|
||||
*/
|
||||
function parseDockerFrame(buffer: Buffer, offset: number): { type: number; size: number; payload: string } | null {
|
||||
if (buffer.length < offset + 8) return null;
|
||||
|
||||
const streamType = buffer.readUInt8(offset);
|
||||
const frameSize = buffer.readUInt32BE(offset + 4);
|
||||
|
||||
if (buffer.length < offset + 8 + frameSize) return null;
|
||||
|
||||
const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8');
|
||||
return { type: streamType, size: 8 + frameSize, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logs streaming for Hawser Edge connections
|
||||
*/
|
||||
async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number): Promise<Response> {
|
||||
// Check if edge agent is connected
|
||||
if (!isEdgeConnected(environmentId)) {
|
||||
return new Response(JSON.stringify({ error: 'Edge agent not connected' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// First, check if container has TTY enabled and get container name
|
||||
let hasTty = false;
|
||||
let containerName = containerId.substring(0, 12); // Default to short ID
|
||||
try {
|
||||
const inspectPath = `/containers/${containerId}/json`;
|
||||
const inspectResponse = await sendEdgeRequest(environmentId, 'GET', inspectPath);
|
||||
if (inspectResponse.statusCode === 200) {
|
||||
const info = JSON.parse(inspectResponse.body as string);
|
||||
hasTty = info.Config?.Tty ?? false;
|
||||
// Get container name (strip leading /)
|
||||
containerName = info.Name?.replace(/^\//, '') || containerName;
|
||||
}
|
||||
} catch {
|
||||
// Ignore - default to demux mode
|
||||
}
|
||||
|
||||
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`;
|
||||
|
||||
let controllerClosed = false;
|
||||
let cancelStream: (() => void) | null = null;
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Send heartbeat to keep connection alive (every 5s for Traefik)
|
||||
heartbeatInterval = setInterval(() => {
|
||||
safeEnqueue(`: keepalive\n\n`);
|
||||
}, 5000);
|
||||
|
||||
// Buffer for non-TTY stream demuxing
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
// Send connected event
|
||||
safeEnqueue(`event: connected\ndata: ${JSON.stringify({ containerId, containerName, hasTty })}\n\n`);
|
||||
|
||||
// Start streaming logs via Edge
|
||||
const { cancel } = sendEdgeStreamRequest(
|
||||
environmentId,
|
||||
'GET',
|
||||
logsPath,
|
||||
{
|
||||
onData: (data: string, streamType?: 'stdout' | 'stderr') => {
|
||||
if (controllerClosed) return;
|
||||
|
||||
if (hasTty) {
|
||||
// TTY mode: data is raw text, may be base64 encoded
|
||||
let text = data;
|
||||
try {
|
||||
// Try to decode as base64
|
||||
text = Buffer.from(data, 'base64').toString('utf-8');
|
||||
} catch {
|
||||
// Not base64, use as-is
|
||||
}
|
||||
if (text) {
|
||||
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
|
||||
}
|
||||
} else {
|
||||
// Non-TTY mode: data might be base64 encoded Docker multiplexed stream
|
||||
let rawData: Buffer;
|
||||
try {
|
||||
rawData = Buffer.from(data, 'base64');
|
||||
} catch {
|
||||
rawData = Buffer.from(data, 'utf-8');
|
||||
}
|
||||
|
||||
buffer = Buffer.concat([buffer, rawData]);
|
||||
|
||||
// Process complete frames
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const frame = parseDockerFrame(buffer, offset);
|
||||
if (!frame) break;
|
||||
|
||||
if (frame.payload) {
|
||||
safeEnqueue(`event: log\ndata: ${JSON.stringify({
|
||||
text: frame.payload,
|
||||
containerName,
|
||||
stream: frame.type === 2 ? 'stderr' : 'stdout'
|
||||
})}\n\n`);
|
||||
}
|
||||
offset += frame.size;
|
||||
}
|
||||
|
||||
// Keep remaining incomplete frame data
|
||||
buffer = buffer.slice(offset);
|
||||
}
|
||||
},
|
||||
onEnd: (reason?: string) => {
|
||||
if (buffer.length > 0) {
|
||||
const text = buffer.toString('utf-8');
|
||||
if (text.trim()) {
|
||||
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
|
||||
}
|
||||
}
|
||||
safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: reason || 'stream ended' })}\n\n`);
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error: string) => {
|
||||
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error })}\n\n`);
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
cancelStream = cancel;
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (cancelStream) {
|
||||
cancelStream();
|
||||
cancelStream = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const containerId = params.id;
|
||||
const tail = url.searchParams.get('tail') || '100';
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) {
|
||||
return new Response(JSON.stringify({ error: 'Permission denied' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const config = await getDockerConfig(envIdNum);
|
||||
|
||||
// Handle Hawser Edge mode separately
|
||||
if (config.type === 'hawser-edge') {
|
||||
return handleEdgeLogsStream(containerId, tail, config.environmentId!);
|
||||
}
|
||||
|
||||
// First, check if container has TTY enabled and get container name
|
||||
let hasTty = false;
|
||||
let containerName = containerId.substring(0, 12); // Default to short ID
|
||||
try {
|
||||
const inspectPath = `/containers/${containerId}/json`;
|
||||
let inspectResponse: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath
|
||||
});
|
||||
} else {
|
||||
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
inspectResponse = await fetch(inspectUrl, {
|
||||
headers: inspectHeaders,
|
||||
// @ts-ignore
|
||||
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
if (inspectResponse.ok) {
|
||||
const info = await inspectResponse.json();
|
||||
hasTty = info.Config?.Tty ?? false;
|
||||
// Get container name (strip leading /)
|
||||
containerName = info.Name?.replace(/^\//, '') || containerName;
|
||||
}
|
||||
} catch {
|
||||
// Ignore - default to demux mode
|
||||
}
|
||||
|
||||
// Build the logs URL with follow=true for streaming
|
||||
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`;
|
||||
|
||||
let controllerClosed = false;
|
||||
let abortController: AbortController | null = new AbortController();
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Send heartbeat to keep connection alive (every 5s for Traefik)
|
||||
heartbeatInterval = setInterval(() => {
|
||||
safeEnqueue(`: keepalive\n\n`);
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
response = await fetch(`http://localhost${logsPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath,
|
||||
signal: abortController?.signal
|
||||
});
|
||||
} else {
|
||||
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
|
||||
const logsHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
response = await fetch(logsUrl, {
|
||||
headers: logsHeaders,
|
||||
signal: abortController?.signal,
|
||||
// @ts-ignore
|
||||
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: `Docker API error: ${response.status}` })}\n\n`);
|
||||
if (!controllerClosed) controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send connected event
|
||||
safeEnqueue(`event: connected\ndata: ${JSON.stringify({ containerId, containerName, hasTty })}\n\n`);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'No response body' })}\n\n`);
|
||||
if (!controllerClosed) controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
while (!controllerClosed) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
// Send any remaining buffer content
|
||||
if (buffer.length > 0) {
|
||||
const text = buffer.toString('utf-8');
|
||||
if (text.trim()) {
|
||||
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
|
||||
}
|
||||
}
|
||||
safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: 'stream ended' })}\n\n`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
if (hasTty) {
|
||||
// TTY mode: raw text, no demux needed
|
||||
const text = new TextDecoder().decode(value);
|
||||
if (text) {
|
||||
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
|
||||
}
|
||||
} else {
|
||||
// Non-TTY mode: demux Docker stream frames
|
||||
buffer = Buffer.concat([buffer, Buffer.from(value)]);
|
||||
|
||||
// Process complete frames
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const frame = parseDockerFrame(buffer, offset);
|
||||
if (!frame) break;
|
||||
|
||||
// Stream type 1 = stdout, 2 = stderr
|
||||
if (frame.payload) {
|
||||
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text: frame.payload, containerName, stream: frame.type === 2 ? 'stderr' : 'stdout' })}\n\n`);
|
||||
}
|
||||
offset += frame.size;
|
||||
}
|
||||
|
||||
// Keep remaining incomplete frame data
|
||||
buffer = buffer.slice(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
} catch (error) {
|
||||
if (!controllerClosed) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
if (!errorMsg.includes('abort')) {
|
||||
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: errorMsg })}\n\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
abortController?.abort();
|
||||
abortController = null;
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { pauseContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context (pause/unpause uses 'stop' permission)
|
||||
if (auth.authEnabled && !await auth.can('containers', 'stop', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
const containerName = details.Name.replace(/^\//, '');
|
||||
await pauseContainer(params.id, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'pause', params.id, containerName, envIdNum);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to pause container:', error);
|
||||
return json({ error: 'Failed to pause container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { renameContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { renameAutoUpdateSchedule } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context (renaming requires create permission)
|
||||
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { name } = await request.json();
|
||||
if (!name || typeof name !== 'string') {
|
||||
return json({ error: 'New name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get old container name before renaming
|
||||
let oldName = params.id;
|
||||
try {
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
oldName = details.Name?.replace(/^\//, '') || params.id;
|
||||
} catch {
|
||||
// Container might not exist or other error, use ID
|
||||
}
|
||||
|
||||
await renameContainer(params.id, name, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'rename', params.id, name, envIdNum, { previousId: params.id, newName: name });
|
||||
|
||||
// Update schedule if exists
|
||||
try {
|
||||
await renameAutoUpdateSchedule(oldName, name, envIdNum);
|
||||
} catch (error) {
|
||||
console.error('Failed to update schedule name:', error);
|
||||
// Don't fail the rename if schedule update fails
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error renaming container:', error);
|
||||
return json({ error: 'Failed to rename container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { restartContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'restart', 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 });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// Get container name for audit
|
||||
let containerName = params.id;
|
||||
try {
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
containerName = details.Name?.replace(/^\//, '') || params.id;
|
||||
} catch {
|
||||
// Use ID if can't get name
|
||||
}
|
||||
|
||||
await restartContainer(params.id, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'restart', params.id, containerName, envIdNum);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error restarting container:', error);
|
||||
return json({ error: 'Failed to restart container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { startContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'start', 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 });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
await startContainer(params.id, envIdNum);
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
const containerName = details.Name.replace(/^\//, '');
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'start', params.id, containerName, envIdNum);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error starting container:', error);
|
||||
return json({ error: 'Failed to start container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getContainerStats } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
|
||||
function calculateCpuPercent(stats: any): number {
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
|
||||
|
||||
if (systemDelta > 0 && cpuDelta > 0) {
|
||||
return (cpuDelta / systemDelta) * cpuCount * 100;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calculateNetworkIO(stats: any): { rx: number; tx: number } {
|
||||
let rx = 0;
|
||||
let tx = 0;
|
||||
|
||||
if (stats.networks) {
|
||||
for (const iface of Object.values(stats.networks) as any[]) {
|
||||
rx += iface.rx_bytes || 0;
|
||||
tx += iface.tx_bytes || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { rx, tx };
|
||||
}
|
||||
|
||||
function calculateBlockIO(stats: any): { read: number; write: number } {
|
||||
let read = 0;
|
||||
let write = 0;
|
||||
|
||||
const ioStats = stats.blkio_stats?.io_service_bytes_recursive;
|
||||
if (Array.isArray(ioStats)) {
|
||||
for (const entry of ioStats) {
|
||||
if (entry.op === 'read' || entry.op === 'Read') {
|
||||
read += entry.value || 0;
|
||||
} else if (entry.op === 'write' || entry.op === 'Write') {
|
||||
write += entry.value || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context (stats uses view permission)
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Early return if no environments configured (fresh install)
|
||||
if (!await hasEnvironments()) {
|
||||
return json({ error: 'No environment configured' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await getContainerStats(params.id, envIdNum) as any;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
const memoryUsage = stats.memory_stats?.usage || 0;
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memoryUsage / memoryLimit) * 100;
|
||||
const networkIO = calculateNetworkIO(stats);
|
||||
const blockIO = calculateBlockIO(stats);
|
||||
|
||||
return json({
|
||||
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
||||
memoryUsage,
|
||||
memoryLimit,
|
||||
memoryPercent: Math.round(memoryPercent * 100) / 100,
|
||||
networkRx: networkIO.rx,
|
||||
networkTx: networkIO.tx,
|
||||
blockRead: blockIO.read,
|
||||
blockWrite: blockIO.write,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get container stats:', error);
|
||||
return json({ error: error.message || 'Failed to get stats' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { stopContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'stop', 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 });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
const containerName = details.Name.replace(/^\//, '');
|
||||
await stopContainer(params.id, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'stop', params.id, containerName, envIdNum);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error stopping container:', error);
|
||||
return json({ error: 'Failed to stop container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { execInContainer, getContainerTop } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null {
|
||||
const lines = output.trim().split('\n').filter(line => line.trim());
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
const headerLine = lines[0];
|
||||
const headers = headerLine.trim().split(/\s+/);
|
||||
|
||||
// Find the index of COMMAND (last column, can have spaces)
|
||||
const commandIndex = headers.findIndex(h => h === 'COMMAND' || h === 'CMD');
|
||||
|
||||
const processes = lines.slice(1).map(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
// COMMAND can have spaces, so join everything from commandIndex onwards
|
||||
if (commandIndex !== -1 && parts.length > commandIndex) {
|
||||
const beforeCommand = parts.slice(0, commandIndex);
|
||||
const command = parts.slice(commandIndex).join(' ');
|
||||
return [...beforeCommand, command];
|
||||
}
|
||||
return parts;
|
||||
});
|
||||
|
||||
return { Titles: headers, Processes: processes };
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context (process list uses inspect permission)
|
||||
if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// Try different ps commands in order of preference
|
||||
const psCommands = [
|
||||
['ps', 'aux', '--sort=-pcpu'], // GNU ps with CPU sort
|
||||
['ps', 'aux'], // GNU ps without sort
|
||||
['ps', '-ef'], // POSIX ps
|
||||
];
|
||||
|
||||
for (const cmd of psCommands) {
|
||||
try {
|
||||
const output = await execInContainer(params.id, cmd, envIdNum);
|
||||
// Check if output looks like an error message (BusyBox error, etc.)
|
||||
if (output.includes('unrecognized option') || output.includes('Usage:') || output.includes('BusyBox')) {
|
||||
continue;
|
||||
}
|
||||
const result = parsePsOutput(output);
|
||||
if (result && result.Processes.length > 0) {
|
||||
return json({ ...result, source: 'ps' });
|
||||
}
|
||||
} catch {
|
||||
// Try next command
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to docker top API
|
||||
const top = await getContainerTop(params.id, envIdNum);
|
||||
return json({ ...top, source: 'top' });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get container processes:', error);
|
||||
return json({ error: error.message || 'Failed to get processes' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { unpauseContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context (unpause uses 'start' permission)
|
||||
if (auth.authEnabled && !await auth.can('containers', 'start', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
const containerName = details.Name.replace(/^\//, '');
|
||||
await unpauseContainer(params.id, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'unpause', params.id, containerName, envIdNum);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to unpause container:', error);
|
||||
return json({ error: 'Failed to unpause container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { updateContainer, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { removePendingContainerUpdate } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, url, cookies } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context (update requires create permission)
|
||||
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { startAfterUpdate, ...options } = body;
|
||||
|
||||
console.log(`Updating container ${params.id} with name: ${options.name}`);
|
||||
|
||||
const container = await updateContainer(params.id, options, startAfterUpdate, envIdNum);
|
||||
|
||||
// Clear pending update indicator (if any) since container was just updated
|
||||
if (envIdNum) {
|
||||
await removePendingContainerUpdate(envIdNum, params.id).catch(() => {
|
||||
// Ignore errors - record may not exist
|
||||
});
|
||||
}
|
||||
|
||||
// Audit log - include full options to see what was modified
|
||||
await auditContainer(event, 'update', container.id, options.name, envIdNum, { ...options, startAfterUpdate });
|
||||
|
||||
return json({ success: true, id: container.id });
|
||||
} catch (error) {
|
||||
console.error('Error updating container:', error);
|
||||
return json({ error: 'Failed to update container', details: String(error) }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,548 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import {
|
||||
listContainers,
|
||||
inspectContainer,
|
||||
stopContainer,
|
||||
removeContainer,
|
||||
createContainer,
|
||||
pullImage,
|
||||
getTempImageTag,
|
||||
isDigestBasedImage,
|
||||
getImageIdByTag,
|
||||
removeTempImage,
|
||||
tagImage
|
||||
} from '$lib/server/docker';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
|
||||
export interface ScanResult {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
negligible?: number;
|
||||
unknown?: number;
|
||||
}
|
||||
|
||||
export interface ScannerResult extends ScanResult {
|
||||
scanner: 'grype' | 'trivy';
|
||||
}
|
||||
|
||||
export interface UpdateProgress {
|
||||
type: 'start' | 'progress' | 'pull_log' | 'scan_start' | 'scan_log' | 'scan_complete' | 'blocked' | 'complete' | 'error';
|
||||
containerId?: string;
|
||||
containerName?: string;
|
||||
step?: 'pulling' | 'scanning' | 'stopping' | 'removing' | 'creating' | 'starting' | 'done' | 'failed' | 'blocked' | 'skipped';
|
||||
message?: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
summary?: {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
blocked: number;
|
||||
skipped: number;
|
||||
};
|
||||
// Pull log specific fields
|
||||
pullStatus?: string;
|
||||
pullId?: string;
|
||||
pullProgress?: string;
|
||||
// Scan specific fields
|
||||
scanResult?: ScanResult;
|
||||
scannerResults?: ScannerResult[];
|
||||
blockReason?: string;
|
||||
scanner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update containers with streaming progress.
|
||||
* Expects JSON body: { containerIds: string[], vulnerabilityCriteria?: VulnerabilityCriteria }
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { url, cookies, request } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Need create permission to recreate containers
|
||||
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { containerIds: string[]; vulnerabilityCriteria?: VulnerabilityCriteria };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { containerIds, vulnerabilityCriteria = 'never' } = body;
|
||||
|
||||
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
|
||||
return json({ error: 'containerIds array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const safeEnqueue = (data: UpdateProgress) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Send SSE keepalive comments every 5s to prevent Traefik (10s idle timeout) from closing connection
|
||||
keepaliveInterval = setInterval(() => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let blockedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Get scanner settings for this environment
|
||||
const scannerSettings = await getScannerSettings(envIdNum);
|
||||
// Scan if scanning is enabled (scanner !== 'none')
|
||||
// The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
|
||||
const shouldScan = scannerSettings.scanner !== 'none';
|
||||
|
||||
// Send start event
|
||||
safeEnqueue({
|
||||
type: 'start',
|
||||
total: containerIds.length,
|
||||
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
|
||||
});
|
||||
|
||||
// Process containers sequentially
|
||||
for (let i = 0; i < containerIds.length; i++) {
|
||||
const containerId = containerIds[i];
|
||||
let containerName = 'unknown';
|
||||
|
||||
try {
|
||||
// Find container
|
||||
const containers = await listContainers(true, envIdNum);
|
||||
const container = containers.find(c => c.id === containerId);
|
||||
|
||||
if (!container) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName: 'unknown',
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Container not found'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
containerName = container.name;
|
||||
|
||||
// Get full container config
|
||||
const inspectData = await inspectContainer(containerId, envIdNum) as any;
|
||||
const wasRunning = inspectData.State.Running;
|
||||
const config = inspectData.Config;
|
||||
const hostConfig = inspectData.HostConfig;
|
||||
const imageName = config.Image;
|
||||
const currentImageId = inspectData.Image;
|
||||
|
||||
// Skip Dockhand container - cannot update itself
|
||||
if (isDockhandContainer(imageName)) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - cannot update Dockhand itself`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 1: Pull latest image
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'pulling',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Pulling ${imageName}...`
|
||||
});
|
||||
|
||||
try {
|
||||
await pullImage(imageName, (data: any) => {
|
||||
// Send pull progress as log entries
|
||||
if (data.status) {
|
||||
safeEnqueue({
|
||||
type: 'pull_log',
|
||||
containerId,
|
||||
containerName,
|
||||
pullStatus: data.status,
|
||||
pullId: data.id,
|
||||
pullProgress: data.progress
|
||||
});
|
||||
}
|
||||
}, envIdNum);
|
||||
} catch (pullError: any) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: `Pull failed: ${pullError.message}`
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// SAFE-PULL FLOW with vulnerability scanning
|
||||
if (shouldScan && !isDigestBasedImage(imageName)) {
|
||||
const tempTag = getTempImageTag(imageName);
|
||||
|
||||
// Get new image ID
|
||||
const newImageId = await getImageIdByTag(imageName, envIdNum);
|
||||
if (!newImageId) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Failed to get new image ID after pull'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Restore original tag to old image (safety)
|
||||
const [oldRepo, oldTag] = parseImageNameAndTag(imageName);
|
||||
try {
|
||||
await tagImage(currentImageId, oldRepo, oldTag, envIdNum);
|
||||
} catch {
|
||||
// Ignore - old image might have been removed
|
||||
}
|
||||
|
||||
// Tag new image with temp suffix
|
||||
const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
|
||||
await tagImage(newImageId, tempRepo, tempTagName, envIdNum);
|
||||
|
||||
// Step 2: Scan temp image
|
||||
safeEnqueue({
|
||||
type: 'scan_start',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'scanning',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Scanning ${imageName} for vulnerabilities...`
|
||||
});
|
||||
|
||||
let scanBlocked = false;
|
||||
let blockReason = '';
|
||||
let finalScanResult: ScanResult | undefined;
|
||||
let individualScannerResults: ScannerResult[] = [];
|
||||
|
||||
try {
|
||||
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
|
||||
if (progress.message) {
|
||||
safeEnqueue({
|
||||
type: 'scan_log',
|
||||
containerId,
|
||||
containerName,
|
||||
scanner: progress.scanner,
|
||||
message: progress.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (scanResults.length > 0) {
|
||||
const scanSummary = combineScanSummaries(scanResults);
|
||||
finalScanResult = {
|
||||
critical: scanSummary.critical,
|
||||
high: scanSummary.high,
|
||||
medium: scanSummary.medium,
|
||||
low: scanSummary.low,
|
||||
negligible: scanSummary.negligible,
|
||||
unknown: scanSummary.unknown
|
||||
};
|
||||
|
||||
// Build individual scanner results
|
||||
individualScannerResults = scanResults.map(result => ({
|
||||
scanner: result.scanner as 'grype' | 'trivy',
|
||||
critical: result.summary.critical,
|
||||
high: result.summary.high,
|
||||
medium: result.summary.medium,
|
||||
low: result.summary.low,
|
||||
negligible: result.summary.negligible,
|
||||
unknown: result.summary.unknown
|
||||
}));
|
||||
|
||||
// Save scan results
|
||||
for (const result of scanResults) {
|
||||
try {
|
||||
await saveVulnerabilityScan({
|
||||
environmentId: envIdNum,
|
||||
imageId: newImageId,
|
||||
imageName: result.imageName,
|
||||
scanner: result.scanner,
|
||||
scannedAt: result.scannedAt,
|
||||
scanDuration: result.scanDuration,
|
||||
criticalCount: result.summary.critical,
|
||||
highCount: result.summary.high,
|
||||
mediumCount: result.summary.medium,
|
||||
lowCount: result.summary.low,
|
||||
negligibleCount: result.summary.negligible,
|
||||
unknownCount: result.summary.unknown,
|
||||
vulnerabilities: result.vulnerabilities,
|
||||
error: result.error ?? null
|
||||
});
|
||||
} catch { /* ignore save errors */ }
|
||||
}
|
||||
|
||||
// Check if blocked
|
||||
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
|
||||
if (blocked) {
|
||||
scanBlocked = true;
|
||||
blockReason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
safeEnqueue({
|
||||
type: 'scan_complete',
|
||||
containerId,
|
||||
containerName,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
message: finalScanResult
|
||||
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
|
||||
: 'Scan complete: no vulnerabilities found'
|
||||
});
|
||||
|
||||
} catch (scanErr: any) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: `Scan failed: ${scanErr.message}`
|
||||
});
|
||||
|
||||
// Clean up temp image on scan failure
|
||||
try {
|
||||
await removeTempImage(newImageId, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scanBlocked) {
|
||||
// BLOCKED - Remove temp image and skip this container
|
||||
safeEnqueue({
|
||||
type: 'blocked',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'blocked',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
blockReason,
|
||||
message: `Update blocked: ${blockReason}`
|
||||
});
|
||||
|
||||
try {
|
||||
await removeTempImage(newImageId, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
blockedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// APPROVED - Re-tag to original
|
||||
await tagImage(newImageId, oldRepo, oldTag, envIdNum);
|
||||
try {
|
||||
await removeTempImage(tempTag, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
|
||||
// Step 3: Stop container if running
|
||||
if (wasRunning) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'stopping',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Stopping ${containerName}...`
|
||||
});
|
||||
await stopContainer(containerId, envIdNum);
|
||||
}
|
||||
|
||||
// Step 4: Remove old container
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'removing',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Removing old container ${containerName}...`
|
||||
});
|
||||
await removeContainer(containerId, true, envIdNum);
|
||||
|
||||
// Prepare port bindings
|
||||
const ports: { [key: string]: { HostPort: string } } = {};
|
||||
if (hostConfig.PortBindings) {
|
||||
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
|
||||
if (bindings && (bindings as any[]).length > 0) {
|
||||
ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Create new container
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Creating new container ${containerName}...`
|
||||
});
|
||||
|
||||
const newContainer = await createContainer({
|
||||
name: containerName,
|
||||
image: imageName,
|
||||
ports,
|
||||
volumeBinds: hostConfig.Binds || [],
|
||||
env: config.Env || [],
|
||||
labels: config.Labels || {},
|
||||
cmd: config.Cmd || undefined,
|
||||
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
|
||||
networkMode: hostConfig.NetworkMode || undefined
|
||||
}, envIdNum);
|
||||
|
||||
// Step 6: Start if was running
|
||||
if (wasRunning) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'starting',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Starting ${containerName}...`
|
||||
});
|
||||
await newContainer.start();
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
|
||||
|
||||
// Done with this container - use original containerId for UI consistency
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'done',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `${containerName} updated successfully`
|
||||
});
|
||||
successCount++;
|
||||
|
||||
// Clear pending update indicator from database
|
||||
if (envIdNum) {
|
||||
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
|
||||
// Ignore errors - record may not exist
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Send complete event
|
||||
safeEnqueue({
|
||||
type: 'complete',
|
||||
summary: {
|
||||
total: containerIds.length,
|
||||
success: successCount,
|
||||
failed: failCount,
|
||||
blocked: blockedCount,
|
||||
skipped: skippedCount
|
||||
},
|
||||
message: skippedCount > 0 || blockedCount > 0
|
||||
? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
|
||||
: `Updated ${successCount} of ${containerIds.length} containers`
|
||||
});
|
||||
|
||||
clearInterval(keepaliveInterval);
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import {
|
||||
listContainers,
|
||||
inspectContainer,
|
||||
stopContainer,
|
||||
removeContainer,
|
||||
createContainer,
|
||||
pullImage
|
||||
} from '$lib/server/docker';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
|
||||
export interface BatchUpdateResult {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update containers by recreating them with latest images.
|
||||
* Expects JSON body: { containerIds: string[] }
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { url, cookies, request } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Need create permission to recreate containers
|
||||
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { containerIds } = body as { containerIds: string[] };
|
||||
|
||||
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
|
||||
return json({ error: 'containerIds array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const results: BatchUpdateResult[] = [];
|
||||
|
||||
// Process containers sequentially to avoid resource conflicts
|
||||
for (const containerId of containerIds) {
|
||||
try {
|
||||
const containers = await listContainers(true, envIdNum);
|
||||
const container = containers.find(c => c.id === containerId);
|
||||
|
||||
if (!container) {
|
||||
results.push({
|
||||
containerId,
|
||||
containerName: 'unknown',
|
||||
success: false,
|
||||
error: 'Container not found'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get full container config
|
||||
const inspectData = await inspectContainer(containerId, envIdNum) as any;
|
||||
const wasRunning = inspectData.State.Running;
|
||||
const config = inspectData.Config;
|
||||
const hostConfig = inspectData.HostConfig;
|
||||
const imageName = config.Image;
|
||||
const containerName = container.name;
|
||||
|
||||
// Pull latest image first
|
||||
try {
|
||||
await pullImage(imageName, undefined, envIdNum);
|
||||
} catch (pullError: any) {
|
||||
results.push({
|
||||
containerId,
|
||||
containerName,
|
||||
success: false,
|
||||
error: `Pull failed: ${pullError.message}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stop container if running
|
||||
if (wasRunning) {
|
||||
await stopContainer(containerId, envIdNum);
|
||||
}
|
||||
|
||||
// Remove old container
|
||||
await removeContainer(containerId, true, envIdNum);
|
||||
|
||||
// Prepare port bindings
|
||||
const ports: { [key: string]: { HostPort: string } } = {};
|
||||
if (hostConfig.PortBindings) {
|
||||
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
|
||||
if (bindings && (bindings as any[]).length > 0) {
|
||||
ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new container
|
||||
const newContainer = await createContainer({
|
||||
name: containerName,
|
||||
image: imageName,
|
||||
ports,
|
||||
volumeBinds: hostConfig.Binds || [],
|
||||
env: config.Env || [],
|
||||
labels: config.Labels || {},
|
||||
cmd: config.Cmd || undefined,
|
||||
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
|
||||
networkMode: hostConfig.NetworkMode || undefined
|
||||
}, envIdNum);
|
||||
|
||||
// Start if was running
|
||||
if (wasRunning) {
|
||||
await newContainer.start();
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
|
||||
|
||||
results.push({
|
||||
containerId: newContainer.id,
|
||||
containerName,
|
||||
success: true
|
||||
});
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
containerId,
|
||||
containerName: 'unknown',
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
|
||||
return json({
|
||||
success: failCount === 0,
|
||||
results,
|
||||
summary: {
|
||||
total: results.length,
|
||||
success: successCount,
|
||||
failed: failCount
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error in batch update:', error);
|
||||
return json({ error: 'Failed to batch update containers', details: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
imageName: string;
|
||||
hasUpdate: boolean;
|
||||
currentDigest?: string;
|
||||
newDigest?: string;
|
||||
error?: string;
|
||||
isLocalImage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all containers for available image updates.
|
||||
* Returns all results at once after checking in parallel.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Need at least view permission
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear existing pending updates for this environment before checking
|
||||
if (envIdNum) {
|
||||
await clearPendingContainerUpdates(envIdNum);
|
||||
}
|
||||
|
||||
const containers = await listContainers(true, envIdNum);
|
||||
|
||||
// Check container for updates
|
||||
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
|
||||
try {
|
||||
// Get container's image name from config
|
||||
const inspectData = await inspectContainer(container.id, envIdNum) as any;
|
||||
const imageName = inspectData.Config?.Image;
|
||||
const currentImageId = inspectData.Image;
|
||||
|
||||
if (!imageName) {
|
||||
return {
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
imageName: container.image,
|
||||
hasUpdate: false,
|
||||
error: 'Could not determine image name'
|
||||
};
|
||||
}
|
||||
|
||||
// Use shared update detection function
|
||||
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
|
||||
|
||||
return {
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
imageName,
|
||||
hasUpdate: result.hasUpdate,
|
||||
currentDigest: result.currentDigest,
|
||||
newDigest: result.registryDigest,
|
||||
error: result.error,
|
||||
isLocalImage: result.isLocalImage
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
imageName: container.image,
|
||||
hasUpdate: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Check all containers in parallel
|
||||
const results = await Promise.all(containers.map(checkContainer));
|
||||
|
||||
const updatesFound = results.filter(r => r.hasUpdate).length;
|
||||
|
||||
// Save containers with updates to the database for persistence
|
||||
if (envIdNum) {
|
||||
for (const result of results) {
|
||||
if (result.hasUpdate) {
|
||||
await addPendingContainerUpdate(
|
||||
envIdNum,
|
||||
result.containerId,
|
||||
result.containerName,
|
||||
result.imageName
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
total: containers.length,
|
||||
updatesFound,
|
||||
results
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error checking for updates:', error);
|
||||
return json({ error: 'Failed to check for updates', details: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getPendingContainerUpdates, removePendingContainerUpdate } from '$lib/server/db';
|
||||
|
||||
/**
|
||||
* Get pending container updates for an environment.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
if (!envIdNum) {
|
||||
return json({ error: 'Environment ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Need at least view permission
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const pendingUpdates = await getPendingContainerUpdates(envIdNum);
|
||||
return json({
|
||||
environmentId: envIdNum,
|
||||
pendingUpdates: pendingUpdates.map(u => ({
|
||||
containerId: u.containerId,
|
||||
containerName: u.containerName,
|
||||
currentImage: u.currentImage,
|
||||
checkedAt: u.checkedAt
|
||||
}))
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error getting pending updates:', error);
|
||||
return json({ error: 'Failed to get pending updates', details: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a pending container update (e.g., after manual update).
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const containerId = url.searchParams.get('containerId');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
if (!envIdNum || !containerId) {
|
||||
return json({ error: 'Environment ID and container ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Need manage permission to delete
|
||||
if (auth.authEnabled && !await auth.can('containers', 'manage', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await removePendingContainerUpdate(envIdNum, containerId);
|
||||
return json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Error removing pending update:', error);
|
||||
return json({ error: 'Failed to remove pending update', details: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listContainersWithSize } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const sizes = await listContainersWithSize(true, envIdNum);
|
||||
return json(sizes);
|
||||
} catch (error) {
|
||||
console.error('Failed to get container sizes:', error);
|
||||
return json({}, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listContainers, getContainerStats } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import type { ContainerStats } from '$lib/types';
|
||||
|
||||
function calculateCpuPercent(stats: any): number {
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
|
||||
|
||||
if (systemDelta > 0 && cpuDelta > 0) {
|
||||
return (cpuDelta / systemDelta) * cpuCount * 100;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calculateNetworkIO(stats: any): { rx: number; tx: number } {
|
||||
let rx = 0;
|
||||
let tx = 0;
|
||||
|
||||
if (stats.networks) {
|
||||
for (const iface of Object.values(stats.networks) as any[]) {
|
||||
rx += iface.rx_bytes || 0;
|
||||
tx += iface.tx_bytes || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { rx, tx };
|
||||
}
|
||||
|
||||
function calculateBlockIO(stats: any): { read: number; write: number } {
|
||||
let read = 0;
|
||||
let write = 0;
|
||||
|
||||
const ioStats = stats.blkio_stats?.io_service_bytes_recursive;
|
||||
if (Array.isArray(ioStats)) {
|
||||
for (const entry of ioStats) {
|
||||
if (entry.op === 'read' || entry.op === 'Read') {
|
||||
read += entry.value || 0;
|
||||
} else if (entry.op === 'write' || entry.op === 'Write') {
|
||||
write += entry.value || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
// Helper to add timeout to promises
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))
|
||||
]);
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
const debugContainer = url.searchParams.get('debug'); // Get raw stats for specific container
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Early return if no environments configured (fresh install)
|
||||
if (!await hasEnvironments()) {
|
||||
return json([]);
|
||||
}
|
||||
|
||||
// Early return if no environment specified
|
||||
if (!envIdNum) {
|
||||
return json([]);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all running containers with timeout
|
||||
const containers = await withTimeout(
|
||||
listContainers(true, envIdNum),
|
||||
5000, // 5 second timeout
|
||||
[]
|
||||
);
|
||||
const runningContainers = containers.filter(c => c.state === 'running');
|
||||
|
||||
// Debug mode: return raw stats for specific container
|
||||
if (debugContainer) {
|
||||
const container = runningContainers.find(c => c.name === debugContainer);
|
||||
if (container) {
|
||||
const rawStats = await getContainerStats(container.id, envIdNum);
|
||||
return json({
|
||||
name: container.name,
|
||||
memory_stats: (rawStats as any).memory_stats
|
||||
});
|
||||
}
|
||||
return json({ error: 'Container not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get stats for each running container (in parallel with timeout)
|
||||
const statsPromises = runningContainers.map(async (container) => {
|
||||
try {
|
||||
const stats = await withTimeout(
|
||||
getContainerStats(container.id, envIdNum) as Promise<any>,
|
||||
3000, // 3 second timeout per container
|
||||
null
|
||||
);
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
// Use raw memory usage (total memory attributed to container)
|
||||
const memoryUsage = stats.memory_stats?.usage || 0;
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memoryUsage / memoryLimit) * 100;
|
||||
const networkIO = calculateNetworkIO(stats);
|
||||
const blockIO = calculateBlockIO(stats);
|
||||
|
||||
return {
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
||||
memoryUsage,
|
||||
memoryLimit,
|
||||
memoryPercent: Math.round(memoryPercent * 100) / 100,
|
||||
networkRx: networkIO.rx,
|
||||
networkTx: networkIO.tx,
|
||||
blockRead: blockIO.read,
|
||||
blockWrite: blockIO.write
|
||||
};
|
||||
} catch (err) {
|
||||
// Silently skip failed containers
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const allStats = await Promise.all(statsPromises);
|
||||
const validStats = allStats.filter((s): s is ContainerStats => s !== null);
|
||||
|
||||
return json(validStats);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get container stats:', error);
|
||||
return json([], { status: 200 }); // Return empty array instead of error
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user