v1.0.19
This commit is contained in:
1 parent
4b430340db
commit
9c451aedf9
120 files changed
+7777
-6046
No files matched your search
@@ -3,6 +3,7 @@ import { containerEventEmitter } from '$lib/server/event-collector';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -52,6 +53,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
};
|
||||
|
||||
// Send initial connection event
|
||||
|
||||
sendEvent('connected', { timestamp: new Date().toISOString() });
|
||||
|
||||
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
|
||||
@@ -84,6 +86,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
},
|
||||
cancel() {
|
||||
// Cleanup when client disconnects
|
||||
|
||||
clearInterval(heartbeatInterval);
|
||||
if (handleEvent) {
|
||||
containerEventEmitter.off('event', handleEvent);
|
||||
|
||||
@@ -30,11 +30,14 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
// Rate limiting by IP and username
|
||||
const clientIp = getClientAddress();
|
||||
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| request.headers.get('x-real-ip')
|
||||
|| getClientAddress();
|
||||
const rateLimitKey = `${clientIp}:${username}`;
|
||||
|
||||
const { limited, retryAfter } = isRateLimited(rateLimitKey);
|
||||
if (limited) {
|
||||
console.warn(`[Auth] Login rate-limited: user=${username} ip=${clientIp} retryAfter=${retryAfter}s`);
|
||||
return json(
|
||||
{ error: `Too many login attempts. Please try again in ${retryAfter} seconds.` },
|
||||
{ status: 429 }
|
||||
@@ -66,6 +69,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
|
||||
if (!result.success) {
|
||||
recordFailedAttempt(rateLimitKey);
|
||||
console.warn(`[Auth] Login failed: user=${username} provider=${authProviderType} ip=${clientIp} reason=${result.error || 'Authentication failed'}`);
|
||||
return json({ error: result.error || 'Authentication failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -80,12 +84,14 @@ export const POST: RequestHandler = async (event) => {
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user || !(await verifyMfaToken(user.id, mfaToken))) {
|
||||
recordFailedAttempt(rateLimitKey);
|
||||
console.warn(`[Auth] MFA failed: user=${username} ip=${clientIp}`);
|
||||
return json({ error: 'Invalid MFA code' }, { status: 401 });
|
||||
}
|
||||
|
||||
// MFA verified, create session
|
||||
const session = await createUserSession(user.id, authProviderType, cookies);
|
||||
const session = await createUserSession(user.id, authProviderType, cookies, request);
|
||||
clearRateLimit(rateLimitKey);
|
||||
console.log(`[Auth] Login successful: user=${username} provider=${authProviderType} ip=${clientIp} mfa=yes`);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'login', user.username, {
|
||||
@@ -107,8 +113,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
|
||||
// No MFA, create session directly
|
||||
if (result.user) {
|
||||
const session = await createUserSession(result.user.id, authProviderType, cookies);
|
||||
const session = await createUserSession(result.user.id, authProviderType, cookies, request);
|
||||
clearRateLimit(rateLimitKey);
|
||||
console.log(`[Auth] Login successful: user=${result.user.username} provider=${authProviderType} ip=${clientIp} mfa=no`);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'login', result.user.username, {
|
||||
|
||||
@@ -11,8 +11,12 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Get current user before destroying session for audit log
|
||||
const auth = await authorize(cookies);
|
||||
const username = auth.user?.username || 'unknown';
|
||||
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| event.request.headers.get('x-real-ip')
|
||||
|| event.getClientAddress();
|
||||
|
||||
await destroySession(cookies);
|
||||
console.log(`[Auth] Logout: user=${username} ip=${clientIp}`);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'logout', username);
|
||||
|
||||
@@ -17,9 +17,15 @@ export const GET: RequestHandler = async (event) => {
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
// Extract client IP for logging
|
||||
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| event.request.headers.get('x-real-ip')
|
||||
|| event.getClientAddress();
|
||||
|
||||
// Handle error from IdP
|
||||
if (error) {
|
||||
console.error('OIDC error from IdP:', error, errorDescription);
|
||||
console.warn(`[Auth] OIDC login failed: ip=${clientIp} error=${errorDescription || error}`);
|
||||
const errorMsg = encodeURIComponent(errorDescription || error);
|
||||
throw redirect(302, `/login?error=${errorMsg}`);
|
||||
}
|
||||
@@ -33,12 +39,14 @@ export const GET: RequestHandler = async (event) => {
|
||||
const result = await handleOidcCallback(code, state);
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
console.warn(`[Auth] OIDC login failed: ip=${clientIp} error=${result.error || 'Authentication failed'}`);
|
||||
const errorMsg = encodeURIComponent(result.error || 'Authentication failed');
|
||||
throw redirect(302, `/login?error=${errorMsg}`);
|
||||
}
|
||||
|
||||
// Create session
|
||||
await createUserSession(result.user.id, 'oidc', cookies);
|
||||
await createUserSession(result.user.id, 'oidc', cookies, event.request);
|
||||
console.log(`[Auth] OIDC login successful: user=${result.user.username} provider=${result.providerName || 'oidc'} ip=${clientIp}`);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'login', result.user.username, {
|
||||
|
||||
+57
-109
@@ -8,8 +8,6 @@ import {
|
||||
pauseContainer,
|
||||
unpauseContainer,
|
||||
removeContainer,
|
||||
inspectContainer,
|
||||
listContainers,
|
||||
removeImage,
|
||||
removeVolume,
|
||||
removeNetwork
|
||||
@@ -23,6 +21,8 @@ import {
|
||||
} from '$lib/server/stacks';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { prefersJSON } from '$lib/server/sse';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
// SSE Event types
|
||||
export type BatchEventType = 'start' | 'progress' | 'complete' | 'error';
|
||||
@@ -105,15 +105,13 @@ interface BatchRequest {
|
||||
async function processWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
processor: (item: T, index: number) => Promise<void>,
|
||||
signal: AbortSignal
|
||||
processor: (item: T, index: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
let currentIndex = 0;
|
||||
const total = items.length;
|
||||
|
||||
async function processNext(): Promise<void> {
|
||||
while (currentIndex < total) {
|
||||
if (signal.aborted) return;
|
||||
const index = currentIndex++;
|
||||
await processor(items[index], index);
|
||||
}
|
||||
@@ -128,7 +126,7 @@ async function processWithConcurrency<T>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified batch operations endpoint with SSE streaming.
|
||||
* Unified batch operations endpoint (job pattern).
|
||||
* Handles bulk operations for containers, images, volumes, networks, and stacks.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
@@ -182,124 +180,74 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
// Check if audit is needed (enterprise only)
|
||||
const needsAudit = auth.isEnterprise;
|
||||
|
||||
// Create abort controller for cancellation
|
||||
const abortController = new AbortController();
|
||||
// Sync path for API clients that prefer plain JSON (Accept: application/json only)
|
||||
if (prefersJSON(request)) {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
await processWithConcurrency(items, 3, async (item, index) => {
|
||||
const { id, name } = item;
|
||||
try {
|
||||
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
|
||||
successCount++;
|
||||
} catch {
|
||||
failCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const safeEnqueue = (data: BatchEvent) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
};
|
||||
return json({
|
||||
type: 'complete',
|
||||
summary: { total: items.length, success: successCount, failed: failCount }
|
||||
});
|
||||
}
|
||||
|
||||
// Send SSE keepalive comments every 5s
|
||||
keepaliveInterval = setInterval(() => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
abortController.abort();
|
||||
}
|
||||
}, 5000);
|
||||
// Job pattern: create job, process in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
(async () => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// Send start event
|
||||
safeEnqueue({
|
||||
type: 'start',
|
||||
total: items.length
|
||||
appendLine(job, { data: { type: 'start', total: items.length } });
|
||||
|
||||
await processWithConcurrency(items, 3, async (item, index) => {
|
||||
const { id, name } = item;
|
||||
|
||||
appendLine(job, {
|
||||
data: { type: 'progress', id, name, status: 'processing', current: index + 1, total: items.length }
|
||||
});
|
||||
|
||||
// Process items with concurrency of 3
|
||||
await processWithConcurrency(
|
||||
items,
|
||||
3,
|
||||
async (item, index) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
const { id, name } = item;
|
||||
|
||||
// Send processing status
|
||||
safeEnqueue({
|
||||
try {
|
||||
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
|
||||
appendLine(job, {
|
||||
data: { type: 'progress', id, name, status: 'success', current: index + 1, total: items.length }
|
||||
});
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
appendLine(job, {
|
||||
data: {
|
||||
type: 'progress',
|
||||
id,
|
||||
name,
|
||||
status: 'processing',
|
||||
status: 'error',
|
||||
error: error.message || 'Unknown error',
|
||||
current: index + 1,
|
||||
total: items.length
|
||||
});
|
||||
|
||||
try {
|
||||
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
|
||||
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
id,
|
||||
name,
|
||||
status: 'success',
|
||||
current: index + 1,
|
||||
total: items.length
|
||||
});
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
id,
|
||||
name,
|
||||
status: 'error',
|
||||
error: error.message || 'Unknown error',
|
||||
current: index + 1,
|
||||
total: items.length
|
||||
});
|
||||
failCount++;
|
||||
}
|
||||
},
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
// Send complete event
|
||||
safeEnqueue({
|
||||
type: 'complete',
|
||||
summary: {
|
||||
total: items.length,
|
||||
success: successCount,
|
||||
failed: failCount
|
||||
}
|
||||
});
|
||||
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
});
|
||||
failCount++;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
abortController.abort();
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
const completeEvent = {
|
||||
type: 'complete',
|
||||
summary: { total: items.length, success: successCount, failed: failCount }
|
||||
};
|
||||
appendLine(job, { data: completeEvent });
|
||||
completeJob(job, completeEvent);
|
||||
})().catch((err) => failJob(job, err.message));
|
||||
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, DockerConnectionError, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
@@ -40,7 +40,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error listing containers:', error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error('Error listing containers:', error);
|
||||
}
|
||||
// Return empty array instead of error to allow UI to load
|
||||
return json([]);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -50,9 +51,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let extension = '.tar';
|
||||
|
||||
if (format === 'tar.gz') {
|
||||
// Compress with gzip using Bun's native implementation
|
||||
// Compress with gzip
|
||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||
body = Bun.gzipSync(tarData);
|
||||
body = gzipSync(tarData);
|
||||
contentType = 'application/gzip';
|
||||
extension = '.tar.gz';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
|
||||
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
|
||||
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -56,6 +58,7 @@ async function getDockerConfig(envId?: number | null): Promise<DockerClientConfi
|
||||
return { type: 'hawser-edge', environmentId: envId };
|
||||
}
|
||||
const protocol = (env.protocol as 'http' | 'https') || 'http';
|
||||
|
||||
return {
|
||||
type: protocol,
|
||||
host: env.host || 'localhost',
|
||||
@@ -119,6 +122,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
@@ -226,6 +230,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
|
||||
cancelStream = cancel;
|
||||
},
|
||||
cancel() {
|
||||
|
||||
controllerClosed = true;
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
@@ -279,28 +284,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let inspectResponse: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath
|
||||
});
|
||||
inspectResponse = await unixSocketRequest(config.socketPath, inspectPath);
|
||||
} else if (config.type === 'https') {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
inspectResponse = await httpsAgentRequest(config as BaseDockerClientConfig, inspectPath, {}, false, extraHeaders);
|
||||
} else {
|
||||
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectUrl = `http://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
const fetchOpts: any = { headers: inspectHeaders };
|
||||
if (config.type === 'https') {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.skipVerify
|
||||
};
|
||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||
if (config.key) fetchOpts.tls.key = config.key;
|
||||
fetchOpts.keepalive = false;
|
||||
if (process.env.DEBUG_TLS) fetchOpts.verbose = true;
|
||||
}
|
||||
inspectResponse = await fetch(inspectUrl, fetchOpts);
|
||||
inspectResponse = await fetch(inspectUrl, { headers: inspectHeaders });
|
||||
}
|
||||
|
||||
if (inspectResponse.ok) {
|
||||
@@ -322,6 +315,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
@@ -343,32 +337,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
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
|
||||
});
|
||||
response = await unixSocketStreamRequest(config.socketPath, logsPath);
|
||||
} else if (config.type === 'https') {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
response = await httpsAgentRequest(config as BaseDockerClientConfig, logsPath, {}, true, extraHeaders);
|
||||
} else {
|
||||
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
|
||||
const logsUrl = `http://${config.host}:${config.port}${logsPath}`;
|
||||
const logsHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
const fetchOpts: any = {
|
||||
headers: logsHeaders,
|
||||
signal: abortController?.signal
|
||||
};
|
||||
if (config.type === 'https') {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.skipVerify
|
||||
};
|
||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||
if (config.key) fetchOpts.tls.key = config.key;
|
||||
fetchOpts.keepalive = false;
|
||||
if (process.env.DEBUG_TLS) fetchOpts.verbose = true;
|
||||
}
|
||||
response = await fetch(logsUrl, fetchOpts);
|
||||
response = await fetch(logsUrl, { headers: logsHeaders });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -434,7 +412,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
} catch (error) {
|
||||
if (!controllerClosed) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
@@ -444,7 +423,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up on normal stream end (not just cancel)
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (!controllerClosed) {
|
||||
controllerClosed = true;
|
||||
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
@@ -453,7 +439,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
if (!controllerClosed) {
|
||||
controllerClosed = true;
|
||||
|
||||
}
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
export interface ScanResult {
|
||||
critical: number;
|
||||
@@ -96,423 +97,123 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'containerIds array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
// Job pattern: create job, run in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
const sendData = (data: UpdateProgress) => {
|
||||
appendLine(job, { data });
|
||||
};
|
||||
|
||||
// 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);
|
||||
(async () => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let blockedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
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';
|
||||
|
||||
// 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
|
||||
sendData({
|
||||
type: 'start',
|
||||
total: containerIds.length,
|
||||
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
|
||||
});
|
||||
|
||||
// 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';
|
||||
|
||||
// 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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||
if (isDigestBasedImage(imageName)) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - image pinned to specific digest`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 1: Pull latest image
|
||||
safeEnqueue({
|
||||
if (!container) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'pulling',
|
||||
containerName: 'unknown',
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Pulling ${imageName}...`
|
||||
success: false,
|
||||
error: 'Container not found'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await pullImage(imageName, (data: any) => {
|
||||
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;
|
||||
}
|
||||
containerName = container.name;
|
||||
|
||||
// SAFE-PULL FLOW with vulnerability scanning
|
||||
if (shouldScan && !isDigestBasedImage(imageName)) {
|
||||
const tempTag = getTempImageTag(imageName);
|
||||
// Get full container config
|
||||
const inspectData = await inspectContainer(containerId, envIdNum) as any;
|
||||
const config = inspectData.Config;
|
||||
const imageName = config.Image;
|
||||
const currentImageId = inspectData.Image;
|
||||
|
||||
// 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.output || progress.message) {
|
||||
safeEnqueue({
|
||||
type: 'scan_log',
|
||||
containerId,
|
||||
containerName,
|
||||
scanner: progress.scanner,
|
||||
message: progress.output || 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect vulnerabilities from all scanners (cap at 100)
|
||||
const vulnerabilities = scanResults
|
||||
.flatMap(r => r.vulnerabilities || [])
|
||||
.slice(0, 100)
|
||||
.map(v => ({
|
||||
id: v.id,
|
||||
severity: v.severity,
|
||||
package: v.package,
|
||||
version: v.version,
|
||||
fixedVersion: v.fixedVersion,
|
||||
link: v.link,
|
||||
scanner: v.scanner
|
||||
}));
|
||||
|
||||
safeEnqueue({
|
||||
type: 'scan_complete',
|
||||
containerId,
|
||||
containerName,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : 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 */ }
|
||||
}
|
||||
|
||||
// Progress logging function for shared functions
|
||||
const logProgress = (message: string) => {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message
|
||||
});
|
||||
};
|
||||
|
||||
let updateSuccess = false;
|
||||
let newContainerId = containerId;
|
||||
|
||||
safeEnqueue({
|
||||
// Skip Dockhand container - cannot update itself
|
||||
if (isDockhandContainer(imageName)) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Recreating ${containerName}...`
|
||||
});
|
||||
|
||||
updateSuccess = await recreateContainer(containerName, envIdNum, logProgress, imageName);
|
||||
if (updateSuccess) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
if (updatedContainer) {
|
||||
newContainerId = updatedContainer.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateSuccess) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Container recreation failed'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
|
||||
|
||||
// Done with this container - use original containerId for UI consistency
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'done',
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `${containerName} updated successfully`
|
||||
message: `Skipping ${containerName} - cannot update Dockhand itself`
|
||||
});
|
||||
successCount++;
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clear pending update indicator from database
|
||||
if (envIdNum) {
|
||||
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
|
||||
// Ignore errors - record may not exist
|
||||
});
|
||||
}
|
||||
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||
if (isDigestBasedImage(imageName)) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - image pinned to specific digest`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
safeEnqueue({
|
||||
// Step 1: Pull latest image
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'pulling',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Pulling ${imageName}...`
|
||||
});
|
||||
|
||||
try {
|
||||
await pullImage(imageName, (data: any) => {
|
||||
if (data.status) {
|
||||
sendData({
|
||||
type: 'pull_log',
|
||||
containerId,
|
||||
containerName,
|
||||
pullStatus: data.status,
|
||||
pullId: data.id,
|
||||
pullProgress: data.progress
|
||||
});
|
||||
}
|
||||
}, envIdNum);
|
||||
} catch (pullError: any) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
@@ -520,53 +221,310 @@ export const POST: RequestHandler = async (event) => {
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: error.message
|
||||
error: `Pull failed: ${pullError.message}`
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Send complete event
|
||||
safeEnqueue({
|
||||
type: 'complete',
|
||||
summary: {
|
||||
// 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) {
|
||||
sendData({
|
||||
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
|
||||
sendData({
|
||||
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.output || progress.message) {
|
||||
sendData({
|
||||
type: 'scan_log',
|
||||
containerId,
|
||||
containerName,
|
||||
scanner: progress.scanner,
|
||||
message: progress.output || 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect vulnerabilities from all scanners (cap at 100)
|
||||
const vulnerabilities = scanResults
|
||||
.flatMap(r => r.vulnerabilities || [])
|
||||
.slice(0, 100)
|
||||
.map(v => ({
|
||||
id: v.id,
|
||||
severity: v.severity,
|
||||
package: v.package,
|
||||
version: v.version,
|
||||
fixedVersion: v.fixedVersion,
|
||||
link: v.link,
|
||||
scanner: v.scanner
|
||||
}));
|
||||
|
||||
sendData({
|
||||
type: 'scan_complete',
|
||||
containerId,
|
||||
containerName,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : 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) {
|
||||
sendData({
|
||||
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
|
||||
sendData({
|
||||
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 */ }
|
||||
}
|
||||
|
||||
// Progress logging function for shared functions
|
||||
const logProgress = (message: string) => {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message
|
||||
});
|
||||
};
|
||||
|
||||
let newContainerId = containerId;
|
||||
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
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`
|
||||
});
|
||||
message: `Recreating ${containerName}...`
|
||||
});
|
||||
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
}
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
controllerClosed = true;
|
||||
} catch {
|
||||
// Controller already closed - ignore
|
||||
controllerClosed = true;
|
||||
const recreateResult = await recreateContainer(containerName, envIdNum, logProgress, imageName);
|
||||
if (recreateResult.success) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
if (updatedContainer) {
|
||||
newContainerId = updatedContainer.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
|
||||
if (!recreateResult.success) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: recreateResult.error || 'Container recreation failed'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
|
||||
|
||||
// Done with this container - use original containerId for UI consistency
|
||||
sendData({
|
||||
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) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Send complete event
|
||||
const completeData: UpdateProgress = {
|
||||
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`
|
||||
};
|
||||
sendData(completeData);
|
||||
completeJob(job, completeData);
|
||||
})().catch((err) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
@@ -75,11 +75,10 @@ export const POST: RequestHandler = async (event) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
let updateSuccess = false;
|
||||
let newContainerId = containerId;
|
||||
|
||||
updateSuccess = await recreateContainer(containerName, envIdNum);
|
||||
if (updateSuccess) {
|
||||
const recreateResult = await recreateContainer(containerName, envIdNum);
|
||||
if (recreateResult.success) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
if (updatedContainer) {
|
||||
@@ -87,12 +86,12 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateSuccess) {
|
||||
if (!recreateResult.success) {
|
||||
results.push({
|
||||
containerId,
|
||||
containerName,
|
||||
success: false,
|
||||
error: 'Container recreation failed'
|
||||
error: recreateResult.error || 'Container recreation failed'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
// Get all running containers with timeout
|
||||
const containers = await withTimeout(
|
||||
listContainers(true, envIdNum),
|
||||
5000, // 5 second timeout
|
||||
10000, // 10 second timeout
|
||||
[]
|
||||
);
|
||||
const runningContainers = containers.filter(c => c.state === 'running');
|
||||
@@ -127,7 +127,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
try {
|
||||
const stats = await withTimeout(
|
||||
getContainerStats(container.id, envIdNum) as Promise<any>,
|
||||
3000, // 3 second timeout per container
|
||||
8000, // 8 second timeout per container (TLS proxy + Docker CPU sampling needs ~2s)
|
||||
null
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listContainers, getContainerStats, EnvironmentNotFoundError } 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 };
|
||||
}
|
||||
|
||||
function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
|
||||
const raw = memoryStats?.usage || 0;
|
||||
const stats = memoryStats?.stats || {};
|
||||
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
|
||||
const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
|
||||
return { usage, raw, cache };
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<T>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(fallback), ms);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
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 (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return new Response(JSON.stringify({ error: 'Permission denied' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (!await hasEnvironments() || !envIdNum) {
|
||||
return new Response('event: done\ndata: {}\n\n', {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let controllerClosed = false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const containers = await withTimeout(
|
||||
listContainers(true, envIdNum),
|
||||
10000,
|
||||
[]
|
||||
);
|
||||
const runningContainers = containers.filter(c => c.state === 'running');
|
||||
|
||||
const statsPromises = runningContainers.map(async (container) => {
|
||||
try {
|
||||
const stats = await withTimeout(
|
||||
getContainerStats(container.id, envIdNum) as Promise<any>,
|
||||
8000,
|
||||
null
|
||||
);
|
||||
|
||||
if (!stats) return;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
const memory = calculateMemoryUsage(stats.memory_stats);
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memory.usage / memoryLimit) * 100;
|
||||
const networkIO = calculateNetworkIO(stats);
|
||||
const blockIO = calculateBlockIO(stats);
|
||||
|
||||
const stat: ContainerStats = {
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
||||
memoryUsage: memory.usage,
|
||||
memoryRaw: memory.raw,
|
||||
memoryCache: memory.cache,
|
||||
memoryLimit,
|
||||
memoryPercent: Math.round(memoryPercent * 100) / 100,
|
||||
networkRx: networkIO.rx,
|
||||
networkTx: networkIO.tx,
|
||||
blockRead: blockIO.read,
|
||||
blockWrite: blockIO.write
|
||||
};
|
||||
|
||||
safeEnqueue(`event: stat\ndata: ${JSON.stringify(stat)}\n\n`);
|
||||
} catch {
|
||||
// Skip failed containers silently
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(statsPromises);
|
||||
} catch (error: any) {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'Environment not found' })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!controllerClosed) {
|
||||
safeEnqueue(`event: done\ndata: {}\n\n`);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getEnvironments,
|
||||
getLatestHostMetrics,
|
||||
getHostMetrics,
|
||||
getMetricsCollectionInterval,
|
||||
getContainerEventStats,
|
||||
getContainerEvents,
|
||||
getEnvSetting,
|
||||
@@ -14,13 +15,17 @@ import {
|
||||
listImages,
|
||||
listNetworks,
|
||||
getContainerStats,
|
||||
getDiskUsage
|
||||
getDiskUsage,
|
||||
dockerPing,
|
||||
DockerConnectionError
|
||||
} from '$lib/server/docker';
|
||||
import { listComposeStacks } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { prefersJSON, sseToJSON } from '$lib/server/sse';
|
||||
import type { EnvironmentStats } from '../+server';
|
||||
import { parseLabels } from '$lib/utils/label-colors';
|
||||
|
||||
|
||||
// Skip disk usage collection (Synology NAS performance fix)
|
||||
const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
|
||||
|
||||
@@ -68,6 +73,9 @@ setInterval(() => {
|
||||
}
|
||||
}, 10 * 60 * 1000); // Every 10 minutes
|
||||
|
||||
// Register cache reporter for memory monitoring
|
||||
|
||||
|
||||
async function getCachedDiskUsage(envId: number): Promise<any> {
|
||||
const cached = diskUsageCache.get(envId);
|
||||
const now = Date.now();
|
||||
@@ -125,10 +133,14 @@ function calculateMemoryUsage(memoryStats: any): number {
|
||||
return usage;
|
||||
}
|
||||
|
||||
// Target time window for metrics history charts (15 minutes)
|
||||
const METRICS_HISTORY_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
// Progressive stats loading - returns stats object and emits partial updates via callback
|
||||
async function getEnvironmentStatsProgressive(
|
||||
env: any,
|
||||
onPartialUpdate: (stats: Partial<EnvironmentStats> & { id: number }) => void
|
||||
onPartialUpdate: (stats: Partial<EnvironmentStats> & { id: number }) => void,
|
||||
metricsPointCount: number
|
||||
): Promise<EnvironmentStats> {
|
||||
const envStats: EnvironmentStats = {
|
||||
id: env.id,
|
||||
@@ -187,7 +199,7 @@ async function getEnvironmentStatsProgressive(
|
||||
getLatestHostMetrics(env.id),
|
||||
getContainerEventStats(env.id),
|
||||
getContainerEvents({ environmentId: env.id, limit: 10 }),
|
||||
getHostMetrics(30, env.id),
|
||||
getHostMetrics(metricsPointCount, env.id),
|
||||
getPendingContainerUpdates(env.id)
|
||||
]);
|
||||
|
||||
@@ -237,6 +249,20 @@ async function getEnvironmentStatsProgressive(
|
||||
loading: { ...envStats.loading }
|
||||
});
|
||||
|
||||
// Quick reachability check — if ping fails, skip all expensive Docker API calls
|
||||
if (!await dockerPing(env.id)) {
|
||||
envStats.online = false;
|
||||
envStats.error = 'Environment offline';
|
||||
envStats.loading = undefined;
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: false,
|
||||
error: 'Environment offline',
|
||||
loading: undefined
|
||||
});
|
||||
return envStats;
|
||||
}
|
||||
|
||||
// Helper to get valid size
|
||||
const getValidSize = (size: number | undefined | null): number => {
|
||||
return size && size > 0 ? size : 0;
|
||||
@@ -511,7 +537,7 @@ async function getEnvironmentStatsProgressive(
|
||||
return envStats;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
export const GET: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||
return new Response(JSON.stringify({ error: 'Permission denied' }), {
|
||||
@@ -535,6 +561,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
let controllerClosed = false;
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Safe enqueue that checks if controller is still open
|
||||
@@ -573,17 +600,23 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
}));
|
||||
safeEnqueue(`event: environments\ndata: ${JSON.stringify(envList)}\n\n`);
|
||||
|
||||
// Calculate metrics point count based on configured interval
|
||||
const metricsIntervalMs = await getMetricsCollectionInterval();
|
||||
const metricsPointCount = Math.ceil(METRICS_HISTORY_WINDOW_MS / metricsIntervalMs);
|
||||
|
||||
// Fetch stats for each environment with progressive updates
|
||||
const promises = environments.map(async (env) => {
|
||||
try {
|
||||
await getEnvironmentStatsProgressive(env, (partialStats) => {
|
||||
// Send partial update as it arrives
|
||||
safeEnqueue(`event: partial\ndata: ${JSON.stringify(partialStats)}\n\n`);
|
||||
});
|
||||
}, metricsPointCount);
|
||||
// Send final complete stats event for this environment
|
||||
safeEnqueue(`event: complete\ndata: ${JSON.stringify({ id: env.id })}\n\n`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to get stats for ${env.name}:`, error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error(`Failed to get stats for ${env.name}:`, error);
|
||||
}
|
||||
// Convert technical error to user-friendly message
|
||||
const errorStr = String(error);
|
||||
let friendlyError = 'Connection error';
|
||||
@@ -611,19 +644,25 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
// Called when the client disconnects
|
||||
controllerClosed = true;
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
const sseResponse = new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
|
||||
if (prefersJSON(request)) return sseToJSON(sseResponse);
|
||||
return sseResponse;
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Memory Debug Endpoint
|
||||
*
|
||||
* Returns Node.js memory stats for monitoring.
|
||||
* Only available when MEMORY_MONITOR=true environment variable is set.
|
||||
*
|
||||
* GET /api/debug/memory - Memory stats (with optional ?gc=true to force GC first)
|
||||
* GET /api/debug/memory?gc=true - Force garbage collection before reporting
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker';
|
||||
|
||||
// Track startup time and initial RSS for growth rate calculation
|
||||
const startupTime = Date.now();
|
||||
const startupRss = process.memoryUsage().rss;
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
if (process.env.MEMORY_MONITOR !== 'true') {
|
||||
return json({ error: 'Memory monitor not enabled. Set MEMORY_MONITOR=true.' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Trigger manual heap snapshot
|
||||
if (url.searchParams.has('snapshot')) {
|
||||
const filename = dumpHeapSnapshot();
|
||||
return json({
|
||||
snapshot: filename ? { filename, message: 'Heap snapshot saved' } : { error: 'Failed to save snapshot' }
|
||||
});
|
||||
}
|
||||
|
||||
// List saved snapshots
|
||||
if (url.searchParams.has('snapshots')) {
|
||||
return json({ snapshots: listHeapSnapshots() });
|
||||
}
|
||||
|
||||
// Force GC if requested and available
|
||||
const forceGc = url.searchParams.get('gc') === 'true';
|
||||
if (forceGc && typeof globalThis.gc === 'function') {
|
||||
globalThis.gc();
|
||||
}
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
const heap = v8.getHeapStatistics();
|
||||
const uptimeMs = Date.now() - startupTime;
|
||||
const uptimeHours = uptimeMs / (1000 * 60 * 60);
|
||||
const rssGrowth = mem.rss - startupRss;
|
||||
const rssGrowthPerHour = uptimeHours > 0.01 ? rssGrowth / uptimeHours : 0;
|
||||
|
||||
return json({
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: {
|
||||
ms: uptimeMs,
|
||||
hours: Math.round(uptimeHours * 100) / 100,
|
||||
human: formatUptime(uptimeMs),
|
||||
},
|
||||
gcForced: forceGc && typeof globalThis.gc === 'function',
|
||||
gcAvailable: typeof globalThis.gc === 'function',
|
||||
process: {
|
||||
rss: formatBytes(mem.rss),
|
||||
heapTotal: formatBytes(mem.heapTotal),
|
||||
heapUsed: formatBytes(mem.heapUsed),
|
||||
external: formatBytes(mem.external),
|
||||
arrayBuffers: formatBytes(mem.arrayBuffers),
|
||||
rssRaw: mem.rss,
|
||||
heapTotalRaw: mem.heapTotal,
|
||||
heapUsedRaw: mem.heapUsed,
|
||||
externalRaw: mem.external,
|
||||
arrayBuffersRaw: mem.arrayBuffers,
|
||||
},
|
||||
growth: {
|
||||
rssSinceStartup: formatBytes(rssGrowth),
|
||||
rssPerHour: formatBytes(Math.round(rssGrowthPerHour)),
|
||||
startupRss: formatBytes(startupRss),
|
||||
},
|
||||
v8Heap: {
|
||||
totalHeapSize: formatBytes(heap.total_heap_size),
|
||||
usedHeapSize: formatBytes(heap.used_heap_size),
|
||||
heapSizeLimit: formatBytes(heap.heap_size_limit),
|
||||
totalPhysicalSize: formatBytes(heap.total_physical_size),
|
||||
totalAvailableSize: formatBytes(heap.total_available_size),
|
||||
mallocedMemory: formatBytes(heap.malloced_memory),
|
||||
peakMallocedMemory: formatBytes(heap.peak_malloced_memory),
|
||||
externalMemory: formatBytes(heap.external_memory),
|
||||
numberOfNativeContexts: heap.number_of_native_contexts,
|
||||
numberOfDetachedContexts: heap.number_of_detached_contexts,
|
||||
},
|
||||
system: {
|
||||
totalMemory: formatBytes(os.totalmem()),
|
||||
freeMemory: formatBytes(os.freemem()),
|
||||
cpus: os.cpus().length,
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
nodeVersion: process.version,
|
||||
},
|
||||
rssTracker: getRssStats(),
|
||||
});
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const sign = bytes < 0 ? '-' : '';
|
||||
const abs = Math.abs(bytes);
|
||||
if (abs < 1024) return `${sign}${abs} B`;
|
||||
if (abs < 1024 * 1024) return `${sign}${(abs / 1024).toFixed(1)} KB`;
|
||||
if (abs < 1024 * 1024 * 1024) return `${sign}${(abs / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${sign}${(abs / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatUptime(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
await setEnvironmentPublicIp(env.id, data.publicIp);
|
||||
}
|
||||
|
||||
// Notify subprocesses to pick up the new environment
|
||||
// Notify event collectors to pick up the new environment
|
||||
refreshSubprocessEnvironments();
|
||||
|
||||
// Auto-assign Admin role to creator (Enterprise only)
|
||||
|
||||
@@ -91,7 +91,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Notify subprocesses if collectActivity or collectMetrics setting changed
|
||||
// Notify event collectors if collectActivity or collectMetrics setting changed
|
||||
if (data.collectActivity !== undefined || data.collectMetrics !== undefined) {
|
||||
refreshSubprocessEnvironments();
|
||||
}
|
||||
@@ -178,7 +178,7 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
await deleteImagePruneSettings(id);
|
||||
unregisterSchedule(id, 'image_prune');
|
||||
|
||||
// Notify subprocesses to stop collecting from deleted environment
|
||||
// Notify event collectors to stop collecting from deleted environment
|
||||
refreshSubprocessEnvironments();
|
||||
|
||||
// Audit log
|
||||
|
||||
@@ -71,7 +71,7 @@ export const POST: RequestHandler = async ({ params }) => {
|
||||
}
|
||||
|
||||
// For Hawser Standard mode, fetch Docker info and Hawser info in parallel
|
||||
// (sequential calls can fail due to Bun TLS connection reuse issues)
|
||||
// (parallel calls are more efficient and avoid sequential connection issues)
|
||||
let info: any;
|
||||
let hawserInfo = null;
|
||||
if (env.connectionType === 'hawser-standard') {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '$lib/server/db';
|
||||
import { refreshSchedulesForEnvironment } from '$lib/server/scheduler';
|
||||
|
||||
/** Map of modern IANA timezone names to their canonical equivalents recognized by Bun/ICU */
|
||||
/** Map of modern IANA timezone names to their canonical equivalents recognized by ICU */
|
||||
const TIMEZONE_ALIASES: Record<string, string> = {
|
||||
'Europe/Kyiv': 'Europe/Kiev',
|
||||
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { unixSocketRequest, httpsAgentRequest } from '$lib/server/docker';
|
||||
import type { DockerClientConfig } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
interface TestConnectionRequest {
|
||||
@@ -22,29 +24,19 @@ function cleanPem(pem: string): string {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildTlsOptions(config: TestConnectionRequest): Record<string, any> | undefined {
|
||||
function buildDockerClientConfig(config: TestConnectionRequest): DockerClientConfig | null {
|
||||
const protocol = config.protocol || 'http';
|
||||
if (protocol !== 'https') return undefined;
|
||||
if (protocol !== 'https') return null;
|
||||
|
||||
const tls: Record<string, any> = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host
|
||||
return {
|
||||
type: 'https',
|
||||
host: config.host || 'localhost',
|
||||
port: config.port || 2376,
|
||||
ca: config.tlsCa ? cleanPem(config.tlsCa) || undefined : undefined,
|
||||
cert: config.tlsCert ? cleanPem(config.tlsCert) || undefined : undefined,
|
||||
key: config.tlsKey ? cleanPem(config.tlsKey) || undefined : undefined,
|
||||
skipVerify: config.tlsSkipVerify || false
|
||||
};
|
||||
if (config.tlsSkipVerify) {
|
||||
tls.rejectUnauthorized = false;
|
||||
} else {
|
||||
tls.rejectUnauthorized = true;
|
||||
if (config.tlsCa) {
|
||||
tls.ca = [cleanPem(config.tlsCa)];
|
||||
}
|
||||
}
|
||||
if (config.tlsCert) {
|
||||
tls.cert = [cleanPem(config.tlsCert)];
|
||||
}
|
||||
if (config.tlsKey) {
|
||||
tls.key = cleanPem(config.tlsKey);
|
||||
}
|
||||
return tls;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,11 +51,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
|
||||
if (config.connectionType === 'socket') {
|
||||
const socketPath = config.socketPath || '/var/run/docker.sock';
|
||||
response = await fetch('http://localhost/info', {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: socketPath,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
response = await unixSocketRequest(socketPath, '/info');
|
||||
} else if (config.connectionType === 'hawser-edge') {
|
||||
// Edge mode - cannot test directly, agent connects to us
|
||||
return json({
|
||||
@@ -83,7 +71,6 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
return json({ success: false, error: 'Host is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = `${protocol}://${host}:${port}/info`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
@@ -92,19 +79,17 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
headers['X-Hawser-Token'] = config.hawserToken;
|
||||
}
|
||||
|
||||
const fetchOptions: any = {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
keepalive: false
|
||||
};
|
||||
|
||||
const tls = buildTlsOptions(config);
|
||||
if (tls) {
|
||||
fetchOptions.tls = tls;
|
||||
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||
const tlsConfig = buildDockerClientConfig(config);
|
||||
if (tlsConfig) {
|
||||
response = await httpsAgentRequest(tlsConfig, '/info', {}, false, headers);
|
||||
} else {
|
||||
const url = `http://${host}:${port}/info`;
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
keepalive: false
|
||||
});
|
||||
}
|
||||
|
||||
response = await fetch(url, fetchOptions);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -123,21 +108,19 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
if (config.hawserToken) {
|
||||
hawserHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
}
|
||||
const hawserUrl = `${protocol}://${config.host}:${config.port || 2375}/_hawser/info`;
|
||||
|
||||
const fetchOptions: any = {
|
||||
headers: hawserHeaders,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
keepalive: false
|
||||
};
|
||||
|
||||
const tls = buildTlsOptions(config);
|
||||
if (tls) {
|
||||
fetchOptions.tls = tls;
|
||||
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||
let hawserResp: Response;
|
||||
const tlsConfig = buildDockerClientConfig(config);
|
||||
if (tlsConfig) {
|
||||
hawserResp = await httpsAgentRequest(tlsConfig, '/_hawser/info', {}, false, hawserHeaders);
|
||||
} else {
|
||||
const hawserUrl = `http://${config.host}:${config.port || 2375}/_hawser/info`;
|
||||
hawserResp = await fetch(hawserUrl, {
|
||||
headers: hawserHeaders,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
keepalive: false
|
||||
});
|
||||
}
|
||||
|
||||
const hawserResp = await fetch(hawserUrl, fetchOptions);
|
||||
if (hawserResp.ok) {
|
||||
hawserInfo = await hawserResp.json();
|
||||
}
|
||||
@@ -182,6 +165,8 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
message = 'Connection failed - check host and port';
|
||||
} else if (rawMessage.includes('self signed certificate') || rawMessage.includes('UNABLE_TO_VERIFY_LEAF_SIGNATURE')) {
|
||||
message = 'TLS certificate error - provide CA certificate for self-signed certs';
|
||||
} else if (rawMessage.includes('CERT_ALTNAME_INVALID') || rawMessage.includes('ERR_TLS_CERT_ALTNAME_INVALID')) {
|
||||
message = 'Certificate hostname mismatch - your certificate\'s Subject Alternative Name (SAN) doesn\'t match the host. Regenerate with: -addext "subjectAltName=DNS:hostname,IP:x.x.x.x"';
|
||||
} else if (rawMessage.includes('certificate') || rawMessage.includes('SSL') || rawMessage.includes('TLS')) {
|
||||
message = 'TLS/SSL error - check certificate configuration';
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule } from '$lib/server/scheduler';
|
||||
import { secureRandomBytes } from '$lib/server/crypto-fallback';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
@@ -156,20 +157,33 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// If deployNow is set, deploy immediately
|
||||
// If deployNow is set, deploy immediately via SSE to keep connection alive
|
||||
if (data.deployNow) {
|
||||
const deployResult = await deployGitStack(gitStack.id);
|
||||
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
|
||||
return json({
|
||||
...gitStack,
|
||||
deployResult: deployResult
|
||||
});
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const deployResult = await deployGitStack(gitStack.id);
|
||||
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
|
||||
send('result', {
|
||||
...gitStack,
|
||||
deployResult: deployResult
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy git stack:', error);
|
||||
send('result', {
|
||||
...gitStack,
|
||||
deployResult: { success: false, error: 'Failed to deploy git stack' }
|
||||
});
|
||||
}
|
||||
}, request);
|
||||
}
|
||||
|
||||
return json(gitStack);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create git stack:', error);
|
||||
if (error.message?.includes('UNIQUE constraint failed')) {
|
||||
if (error.message?.includes('stack_environment_variables')) {
|
||||
return json({ error: 'Duplicate environment variable keys detected' }, { status: 400 });
|
||||
}
|
||||
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
|
||||
}
|
||||
return json({ error: 'Failed to create git stack' }, { status: 500 });
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars } from '$lib/server/db';
|
||||
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars, deleteStackEnvVars } from '$lib/server/db';
|
||||
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
@@ -131,20 +132,33 @@ export const PUT: RequestHandler = async (event) => {
|
||||
await setStackEnvVars(stackName, envId, varsToSave as any);
|
||||
}
|
||||
|
||||
// If deployNow is set, deploy after saving
|
||||
// If deployNow is set, deploy after saving via SSE to keep connection alive
|
||||
if (data.deployNow) {
|
||||
const deployResult = await deployGitStack(id);
|
||||
await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId);
|
||||
return json({
|
||||
...updated,
|
||||
deployResult
|
||||
});
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const deployResult = await deployGitStack(id);
|
||||
await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId);
|
||||
send('result', {
|
||||
...updated,
|
||||
deployResult
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy git stack:', error);
|
||||
send('result', {
|
||||
...updated,
|
||||
deployResult: { success: false, error: 'Failed to deploy git stack' }
|
||||
});
|
||||
}
|
||||
}, request);
|
||||
}
|
||||
|
||||
return json(updated);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update git stack:', error);
|
||||
if (error.message?.includes('UNIQUE constraint failed')) {
|
||||
if (error.message?.includes('stack_environment_variables')) {
|
||||
return json({ error: 'Duplicate environment variable keys detected' }, { status: 400 });
|
||||
}
|
||||
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
|
||||
}
|
||||
return json({ error: 'Failed to update git stack' }, { status: 500 });
|
||||
@@ -176,6 +190,9 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
// Delete the stack_sources record to free up the stack name
|
||||
await deleteStackSource(existing.stackName, existing.environmentId);
|
||||
|
||||
// Delete all env var overrides for this stack (all environments)
|
||||
await deleteStackEnvVars(existing.stackName);
|
||||
|
||||
// Delete from database
|
||||
await deleteGitStack(id);
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { RequestHandler } from './$types';
|
||||
import { getGitStack } from '$lib/server/db';
|
||||
import { deployGitStackWithProgress } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
import { prefersJSON, sseToJSON } from '$lib/server/sse';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
export const POST: RequestHandler = async ({ params, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const id = parseInt(params.id);
|
||||
@@ -25,30 +27,43 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Create a readable stream for SSE
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const sendEvent = (data: any) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
|
||||
try {
|
||||
await deployGitStackWithProgress(id, sendEvent);
|
||||
} catch (error: any) {
|
||||
sendEvent({ status: 'error', error: error.message || 'Unknown error' });
|
||||
} finally {
|
||||
controller.close();
|
||||
// Backward compat: API clients sending Accept: application/json get synchronous SSE result
|
||||
if (prefersJSON(request)) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const sendEvent = (data: unknown) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
try {
|
||||
await deployGitStackWithProgress(id, sendEvent);
|
||||
} catch (error: any) {
|
||||
sendEvent({ status: 'error', error: error.message || 'Unknown error' });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const sseResponse = new Response(stream, {
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
return sseToJSON(sseResponse);
|
||||
}
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
// Job pattern: fire and forget, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
deployGitStackWithProgress(id, (data: unknown) => {
|
||||
appendLine(job, { data });
|
||||
})
|
||||
.then(() => {
|
||||
const lastLine = job.lines[job.lines.length - 1];
|
||||
const lastData = lastLine?.data as any;
|
||||
completeJob(job, lastData ?? { status: 'complete' });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { getGitStack } from '$lib/server/db';
|
||||
import { deployGitStack } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, cookies } = event;
|
||||
@@ -21,12 +22,19 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await deployGitStack(id);
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const result = await deployGitStack(id);
|
||||
|
||||
// Audit log
|
||||
await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId);
|
||||
// Audit log
|
||||
await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId);
|
||||
|
||||
return json(result);
|
||||
send('result', result);
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy git stack:', error);
|
||||
send('result', { success: false, error: 'Failed to deploy git stack' });
|
||||
}
|
||||
}, event.request);
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy git stack:', error);
|
||||
return json({ error: 'Failed to deploy git stack' }, { status: 500 });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Hawser Edge WebSocket Connect Endpoint
|
||||
*
|
||||
* This endpoint handles WebSocket connections from Hawser agents running in Edge mode.
|
||||
* In development: WebSocket is handled by Bun.serve in vite.config.ts on port 5174
|
||||
* In development: WebSocket is handled by ws.WebSocketServer in vite.config.ts on port 5174
|
||||
* In production: WebSocket is handled by the server wrapper in server.ts
|
||||
*
|
||||
* The HTTP GET endpoint returns connection info for clients.
|
||||
@@ -28,7 +28,7 @@ export const GET: RequestHandler = async () => {
|
||||
hostname: conn.hostname,
|
||||
capabilities: conn.capabilities,
|
||||
connectedAt: conn.connectedAt.toISOString(),
|
||||
lastHeartbeat: conn.lastHeartbeat.toISOString()
|
||||
lastHeartbeat: new Date(conn.lastHeartbeat).toISOString()
|
||||
}));
|
||||
|
||||
return json({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listImages, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { listImages, EnvironmentNotFoundError, DockerConnectionError } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -32,7 +32,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error listing images:', error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error('Error listing images:', error);
|
||||
}
|
||||
// Return empty array instead of error to allow UI to load
|
||||
return json([]);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { pullImage } from '$lib/server/docker';
|
||||
import { pullImage, buildRegistryAuthHeader } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, getEnvironment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditImage } from '$lib/server/audit';
|
||||
import { sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
/**
|
||||
* Check if environment is edge mode
|
||||
@@ -73,49 +74,26 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Check if this is an edge environment
|
||||
const edgeCheck = await isEdgeMode(envId);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>;
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let cancelEdgeStream: (() => void) | null = null;
|
||||
// Job pattern: create job, run in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (cancelEdgeStream) {
|
||||
cancelEdgeStream();
|
||||
cancelEdgeStream = null;
|
||||
}
|
||||
controllerClosed = true;
|
||||
const sendData = (data: unknown) => {
|
||||
appendLine(job, { data });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle scan-on-pull after image is pulled
|
||||
*/
|
||||
const handleScanOnPull = async () => {
|
||||
// Skip if caller explicitly requested no scan (e.g., CreateContainerModal handles scanning separately)
|
||||
if (skipScanOnPull) return;
|
||||
|
||||
const { scanner } = await getScannerSettings(envId);
|
||||
// Scan if scanning is enabled (scanner !== 'none')
|
||||
if (scanner !== 'none') {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'scanning', message: 'Starting vulnerability scan...' })}\n\n`);
|
||||
sendData({ status: 'scanning', message: 'Starting vulnerability scan...' });
|
||||
|
||||
try {
|
||||
const results = await scanImage(image, envId, (progress) => {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'scan-progress', ...progress })}\n\n`);
|
||||
sendData({ status: 'scan-progress', ...progress });
|
||||
});
|
||||
|
||||
for (const result of results) {
|
||||
@@ -138,128 +116,99 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0);
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
sendData({
|
||||
status: 'scan-complete',
|
||||
message: `Scan complete - found ${totalVulns} vulnerabilities`,
|
||||
results
|
||||
})}\n\n`);
|
||||
});
|
||||
} catch (scanError) {
|
||||
console.error('Scan-on-pull failed:', scanError);
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
sendData({
|
||||
status: 'scan-error',
|
||||
error: scanError instanceof Error ? scanError.message : String(scanError)
|
||||
})}\n\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(ctrl) {
|
||||
controller = ctrl;
|
||||
// Run operation in background
|
||||
(async () => {
|
||||
console.log(`Starting pull for image: ${image}${edgeCheck.isEdge ? ' (edge mode)' : ''}`);
|
||||
|
||||
// Start heartbeat to keep connection alive through Traefik (10s idle timeout)
|
||||
heartbeatInterval = setInterval(() => {
|
||||
safeEnqueue(`: keepalive\n\n`);
|
||||
}, 5000);
|
||||
if (edgeCheck.isEdge && edgeCheck.environmentId) {
|
||||
if (!isEdgeConnected(edgeCheck.environmentId)) {
|
||||
sendData({ status: 'error', error: 'Edge agent not connected' });
|
||||
failJob(job, 'Edge agent not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Starting pull for image: ${image}${edgeCheck.isEdge ? ' (edge mode)' : ''}`);
|
||||
|
||||
// Handle edge mode with streaming
|
||||
if (edgeCheck.isEdge && edgeCheck.environmentId) {
|
||||
if (!isEdgeConnected(edgeCheck.environmentId)) {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: 'Edge agent not connected' })}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const pullUrl = buildPullUrl(image);
|
||||
const pullUrl = buildPullUrl(image);
|
||||
const authHeaders = await buildRegistryAuthHeader(image);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const { cancel } = sendEdgeStreamRequest(
|
||||
edgeCheck.environmentId,
|
||||
edgeCheck.environmentId!,
|
||||
'POST',
|
||||
pullUrl,
|
||||
{
|
||||
onData: (data: string) => {
|
||||
// Data is base64 encoded JSON lines from Docker
|
||||
try {
|
||||
const decoded = Buffer.from(data, 'base64').toString('utf-8');
|
||||
// Docker sends newline-delimited JSON
|
||||
const lines = decoded.split('\n').filter(line => line.trim());
|
||||
const lines = decoded.split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const progress = JSON.parse(line);
|
||||
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
sendData(JSON.parse(line));
|
||||
} catch {
|
||||
// Ignore parse errors for partial lines
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If not base64, try as-is
|
||||
try {
|
||||
const progress = JSON.parse(data);
|
||||
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
sendData(JSON.parse(data));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
onEnd: async () => {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'complete' })}\n\n`);
|
||||
|
||||
// Handle scan-on-pull
|
||||
sendData({ status: 'complete' });
|
||||
await handleScanOnPull();
|
||||
|
||||
cleanup();
|
||||
controller.close();
|
||||
completeJob(job, { status: 'complete' });
|
||||
resolve();
|
||||
},
|
||||
onError: (error: string) => {
|
||||
console.error('Edge pull error:', error);
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error })}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
sendData({ status: 'error', error });
|
||||
failJob(job, error);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
authHeaders
|
||||
);
|
||||
|
||||
cancelEdgeStream = cancel;
|
||||
} else {
|
||||
// Non-edge mode: use existing pullImage function
|
||||
try {
|
||||
await pullImage(image, (progress) => {
|
||||
const data = JSON.stringify(progress) + '\n';
|
||||
safeEnqueue(`data: ${data}\n\n`);
|
||||
}, envId);
|
||||
// Store cancel reference (not used currently but available)
|
||||
void cancel;
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await pullImage(image, (progress) => {
|
||||
sendData(progress);
|
||||
}, envId);
|
||||
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'complete' })}\n\n`);
|
||||
|
||||
// Handle scan-on-pull
|
||||
await handleScanOnPull();
|
||||
|
||||
cleanup();
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
console.error('Error pulling image:', error);
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
error: String(error)
|
||||
})}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
}
|
||||
sendData({ status: 'complete' });
|
||||
await handleScanOnPull();
|
||||
completeJob(job, { status: 'complete' });
|
||||
} catch (error) {
|
||||
console.error('Error pulling image:', error);
|
||||
const errMsg = String(error);
|
||||
sendData({ status: 'error', error: errMsg });
|
||||
failJob(job, errMsg);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
cleanup();
|
||||
}
|
||||
})().catch((err) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
@@ -5,6 +5,8 @@ import { getRegistry, getEnvironment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditImage } from '$lib/server/audit';
|
||||
import { sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { prefersJSON } from '$lib/server/sse';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
/**
|
||||
* Check if environment is edge mode
|
||||
@@ -119,35 +121,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Check if this is an edge environment
|
||||
const edgeCheck = await isEdgeMode(envIdNum);
|
||||
|
||||
// Stream the push progress
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>;
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let cancelEdgeStream: (() => void) | null = null;
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (cancelEdgeStream) {
|
||||
cancelEdgeStream();
|
||||
cancelEdgeStream = null;
|
||||
}
|
||||
controllerClosed = true;
|
||||
};
|
||||
|
||||
const formatError = (error: any): string => {
|
||||
const errorMessage = error.message || error || '';
|
||||
let userMessage = errorMessage || 'Failed to push image';
|
||||
@@ -163,140 +136,87 @@ export const POST: RequestHandler = async (event) => {
|
||||
return userMessage;
|
||||
};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(ctrl) {
|
||||
controller = ctrl;
|
||||
// Core push logic — emit callback receives progress data objects
|
||||
async function runPush(emit: (data: unknown) => void): Promise<void> {
|
||||
emit({ status: 'tagging', message: 'Tagging image...' });
|
||||
await tagImage(imageId, repo, tag, envIdNum);
|
||||
emit({ status: 'pushing', message: 'Pushing to registry...' });
|
||||
|
||||
// Start heartbeat to keep connection alive through Traefik (10s idle timeout)
|
||||
heartbeatInterval = setInterval(() => {
|
||||
safeEnqueue(`: keepalive\n\n`);
|
||||
}, 5000);
|
||||
if (edgeCheck.isEdge && edgeCheck.environmentId) {
|
||||
if (!isEdgeConnected(edgeCheck.environmentId)) {
|
||||
emit({ status: 'error', error: 'Edge agent not connected' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send tagging status
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'tagging', message: 'Tagging image...' })}\n\n`);
|
||||
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
|
||||
|
||||
// Tag the image with the target registry
|
||||
await tagImage(imageId, repo, tag, envIdNum);
|
||||
|
||||
// Send pushing status
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'pushing', message: 'Pushing to registry...' })}\n\n`);
|
||||
|
||||
// Handle edge mode with streaming
|
||||
if (edgeCheck.isEdge && edgeCheck.environmentId) {
|
||||
if (!isEdgeConnected(edgeCheck.environmentId)) {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: 'Edge agent not connected' })}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create X-Registry-Auth header
|
||||
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
|
||||
|
||||
const { cancel } = sendEdgeStreamRequest(
|
||||
edgeCheck.environmentId,
|
||||
'POST',
|
||||
`/images/${encodeURIComponent(targetTag)}/push`,
|
||||
{
|
||||
onData: (data: string) => {
|
||||
// Data is base64 encoded JSON lines from Docker
|
||||
try {
|
||||
const decoded = Buffer.from(data, 'base64').toString('utf-8');
|
||||
const lines = decoded.split('\n').filter(line => line.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const progress = JSON.parse(line);
|
||||
if (progress.error) {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(progress.error) })}\n\n`);
|
||||
} else {
|
||||
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors for partial lines
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If not base64, try as-is
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sendEdgeStreamRequest(
|
||||
edgeCheck.environmentId!,
|
||||
'POST',
|
||||
`/images/${encodeURIComponent(targetTag)}/push`,
|
||||
{
|
||||
onData: (data: string) => {
|
||||
try {
|
||||
const decoded = Buffer.from(data, 'base64').toString('utf-8');
|
||||
for (const line of decoded.split('\n').filter((l) => l.trim())) {
|
||||
try {
|
||||
const progress = JSON.parse(data);
|
||||
if (progress.error) {
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(progress.error) })}\n\n`);
|
||||
} else {
|
||||
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const progress = JSON.parse(line);
|
||||
emit(progress.error ? { status: 'error', error: formatError(progress.error) } : progress);
|
||||
} catch { /* ignore partial lines */ }
|
||||
}
|
||||
},
|
||||
onEnd: async () => {
|
||||
// Audit log
|
||||
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
|
||||
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
status: 'complete',
|
||||
message: `Image pushed to ${targetTag}`,
|
||||
targetTag
|
||||
})}\n\n`);
|
||||
|
||||
cleanup();
|
||||
controller.close();
|
||||
},
|
||||
onError: (error: string) => {
|
||||
console.error('Edge push error:', error);
|
||||
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(error) })}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
} catch {
|
||||
try {
|
||||
const progress = JSON.parse(data);
|
||||
emit(progress.error ? { status: 'error', error: formatError(progress.error) } : progress);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{ 'X-Registry-Auth': authHeader }
|
||||
);
|
||||
|
||||
cancelEdgeStream = cancel;
|
||||
} else {
|
||||
// Non-edge mode: use existing pushImage function
|
||||
await pushImage(targetTag, authConfig, (progress) => {
|
||||
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
}, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
|
||||
|
||||
// Send completion message
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
status: 'complete',
|
||||
message: `Image pushed to ${targetTag}`,
|
||||
targetTag
|
||||
})}\n\n`);
|
||||
|
||||
cleanup();
|
||||
controller.close();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error pushing image:', error);
|
||||
safeEnqueue(`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
error: formatError(error)
|
||||
})}\n\n`);
|
||||
cleanup();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
cleanup();
|
||||
onEnd: async () => {
|
||||
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
|
||||
emit({ status: 'complete', message: `Image pushed to ${targetTag}`, targetTag });
|
||||
resolve();
|
||||
},
|
||||
onError: (error: string) => {
|
||||
console.error('Edge push error:', error);
|
||||
emit({ status: 'error', error: formatError(error) });
|
||||
reject(new Error(error));
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{ 'X-Registry-Auth': authHeader }
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await pushImage(targetTag, authConfig, (progress) => emit(progress), envIdNum);
|
||||
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
|
||||
emit({ status: 'complete', message: `Image pushed to ${targetTag}`, targetTag });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
// Sync path for API clients sending Accept: application/json only
|
||||
if (prefersJSON(request)) {
|
||||
try {
|
||||
let lastEvent: unknown = null;
|
||||
await runPush((data) => { lastEvent = data; });
|
||||
return json(lastEvent || { success: true });
|
||||
} catch (error: any) {
|
||||
return json({ status: 'error', error: formatError(error) }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Job pattern: return jobId immediately, push runs in background
|
||||
const job = createJob();
|
||||
(async () => {
|
||||
try {
|
||||
await runPush((data) => appendLine(job, { data }));
|
||||
completeJob(job, job.lines[job.lines.length - 1]?.data ?? { success: true });
|
||||
} catch (error: any) {
|
||||
appendLine(job, { data: { status: 'error', error: formatError(error) } });
|
||||
failJob(job, error.message);
|
||||
}
|
||||
})();
|
||||
return json({ jobId: job.id });
|
||||
} catch (error: any) {
|
||||
console.error('Error setting up push:', error);
|
||||
return json({ error: error.message || 'Failed to push image' }, { status: 500 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { scanImage, type ScanProgress, type ScanResult } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, getLatestScanForImage } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
// Helper to convert ScanResult to database format
|
||||
function scanResultToDbFormat(result: ScanResult, envId?: number) {
|
||||
@@ -23,7 +24,7 @@ function scanResultToDbFormat(result: ScanResult, envId?: number) {
|
||||
};
|
||||
}
|
||||
|
||||
// POST - Start a scan (returns SSE stream for progress)
|
||||
// POST - Start a scan (returns { jobId } for progress polling)
|
||||
export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -42,75 +43,47 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
return json({ error: 'Image name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create a readable stream for SSE
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
// Job pattern: create job, run in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
const sendProgress = (progress: ScanProgress) => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
const data = `data: ${JSON.stringify(progress)}\n\n`;
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
};
|
||||
const sendProgress = (progress: ScanProgress) => {
|
||||
appendLine(job, { data: progress });
|
||||
};
|
||||
|
||||
// Send SSE keepalive comments every 5s to prevent Traefik timeout
|
||||
const keepaliveInterval = setInterval(() => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}, 5000);
|
||||
(async () => {
|
||||
try {
|
||||
const results = await scanImage(imageName, envId, sendProgress, forceScannerType);
|
||||
|
||||
try {
|
||||
const results = await scanImage(imageName, envId, sendProgress, forceScannerType);
|
||||
|
||||
// Save results to database
|
||||
for (const result of results) {
|
||||
await saveVulnerabilityScan(scanResultToDbFormat(result, envId));
|
||||
}
|
||||
|
||||
// Send final complete message with all results
|
||||
sendProgress({
|
||||
stage: 'complete',
|
||||
message: `Scan complete - found ${results.reduce((sum, r) => sum + r.vulnerabilities.length, 0)} vulnerabilities`,
|
||||
progress: 100,
|
||||
result: results[0],
|
||||
results: results // Include all scanner results
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
sendProgress({
|
||||
stage: 'error',
|
||||
message: `Scan failed: ${errorMsg}`,
|
||||
error: errorMsg
|
||||
});
|
||||
} finally {
|
||||
clearInterval(keepaliveInterval);
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
// Save results to database
|
||||
for (const result of results) {
|
||||
await saveVulnerabilityScan(scanResultToDbFormat(result, envId));
|
||||
}
|
||||
|
||||
// Send final complete message with all results
|
||||
const completeProgress: ScanProgress = {
|
||||
stage: 'complete',
|
||||
message: `Scan complete - found ${results.reduce((sum, r) => sum + r.vulnerabilities.length, 0)} vulnerabilities`,
|
||||
progress: 100,
|
||||
result: results[0],
|
||||
results: results // Include all scanner results
|
||||
};
|
||||
sendProgress(completeProgress);
|
||||
completeJob(job, completeProgress);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const errorProgress: ScanProgress = {
|
||||
stage: 'error',
|
||||
message: `Scan failed: ${errorMsg}`,
|
||||
error: errorMsg
|
||||
};
|
||||
sendProgress(errorProgress);
|
||||
failJob(job, errorMsg);
|
||||
}
|
||||
})().catch((err) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
|
||||
// GET - Get cached scan results for an image
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getJob } from '$lib/server/jobs';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET /api/jobs/[id]
|
||||
* Poll a job's status and accumulated lines.
|
||||
* Returns all lines every time — client tracks its own cursor locally.
|
||||
* No auth required: job IDs are UUIDs (unguessable), no sensitive data beyond what the initiating user triggered.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const job = getJob(params.id);
|
||||
if (!job) {
|
||||
return json({ error: 'Job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
lines: job.lines,
|
||||
result: job.result ?? null
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
|
||||
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
|
||||
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -424,37 +426,20 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
let inspectResponse: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath
|
||||
});
|
||||
inspectResponse = await unixSocketRequest(config.socketPath, inspectPath);
|
||||
} else if (config.type === 'https') {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
inspectResponse = await httpsAgentRequest(config as BaseDockerClientConfig, inspectPath, {}, false, extraHeaders);
|
||||
} else {
|
||||
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectUrl = `http://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
|
||||
// Build fetch options - only include tls for HTTPS
|
||||
const fetchOptions: any = {
|
||||
headers: inspectHeaders,
|
||||
signal: AbortSignal.timeout(30000)
|
||||
};
|
||||
if (config.type === 'https') {
|
||||
fetchOptions.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.skipVerify
|
||||
};
|
||||
if (config.ca) fetchOptions.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOptions.tls.cert = [config.cert];
|
||||
if (config.key) fetchOptions.tls.key = config.key;
|
||||
fetchOptions.keepalive = false;
|
||||
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||
}
|
||||
|
||||
inspectResponse = await fetch(inspectUrl, fetchOptions);
|
||||
inspectResponse = await fetch(inspectUrl, { headers: inspectHeaders, signal: AbortSignal.timeout(30000) });
|
||||
}
|
||||
|
||||
if (!inspectResponse.ok) {
|
||||
await inspectResponse.arrayBuffer().catch(() => {});
|
||||
console.log(`[merged-logs] Inspect failed for ${containerId.slice(0, 12)}, skipping`);
|
||||
return null;
|
||||
}
|
||||
@@ -468,39 +453,20 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
let logsResponse: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
logsResponse = await fetch(`http://localhost${logsPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath,
|
||||
signal: abortController.signal
|
||||
});
|
||||
logsResponse = await unixSocketStreamRequest(config.socketPath, logsPath);
|
||||
} else if (config.type === 'https') {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
logsResponse = await httpsAgentRequest(config as BaseDockerClientConfig, logsPath, {}, true, extraHeaders);
|
||||
} else {
|
||||
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
|
||||
const logsUrl = `http://${config.host}:${config.port}${logsPath}`;
|
||||
const logsHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
|
||||
// For logs streaming, use the cleanup abort controller without a timeout
|
||||
// (the stream needs to stay open indefinitely)
|
||||
const fetchOptions: any = {
|
||||
headers: logsHeaders,
|
||||
signal: abortController.signal
|
||||
};
|
||||
if (config.type === 'https') {
|
||||
fetchOptions.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.skipVerify
|
||||
};
|
||||
if (config.ca) fetchOptions.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOptions.tls.cert = [config.cert];
|
||||
if (config.key) fetchOptions.tls.key = config.key;
|
||||
fetchOptions.keepalive = false;
|
||||
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
|
||||
}
|
||||
|
||||
logsResponse = await fetch(logsUrl, fetchOptions);
|
||||
logsResponse = await fetch(logsUrl, { headers: logsHeaders, signal: abortController.signal });
|
||||
}
|
||||
|
||||
if (!logsResponse.ok) {
|
||||
await logsResponse.arrayBuffer().catch(() => {});
|
||||
console.error(`[merged-logs] Failed to get logs for container ${containerId}: ${logsResponse.status}`);
|
||||
return null;
|
||||
}
|
||||
@@ -647,7 +613,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
for (const source of sources) {
|
||||
if (source.reader) {
|
||||
try {
|
||||
source.reader.releaseLock();
|
||||
await source.reader.cancel().catch(() => {});
|
||||
source.reader.releaseLock();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getHostMetrics } from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const limit = parseInt(url.searchParams.get('limit') || '60');
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
const metrics = await getHostMetrics(limit, envIdNum);
|
||||
|
||||
// Return metrics in chronological order (oldest first) for graphing
|
||||
const chronological = metrics.reverse();
|
||||
|
||||
return json({
|
||||
metrics: chronological,
|
||||
latest: metrics.length > 0 ? metrics[metrics.length - 1] : null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get host metrics:', error);
|
||||
return json({ error: 'Failed to get host metrics' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listNetworks, createNetwork, EnvironmentNotFoundError, type CreateNetworkOptions } from '$lib/server/docker';
|
||||
import { listNetworks, createNetwork, EnvironmentNotFoundError, DockerConnectionError, type CreateNetworkOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditNetwork } from '$lib/server/audit';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
@@ -33,7 +33,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Failed to list networks:', error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error('Failed to list networks:', error);
|
||||
}
|
||||
return json({ error: 'Failed to list networks' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { pruneImages } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { audit } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -17,19 +18,21 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pruneImages(danglingOnly, envIdNum);
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const result = await pruneImages(danglingOnly, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await audit(event, 'prune', 'image', {
|
||||
environmentId: envIdNum,
|
||||
description: `Pruned ${danglingOnly ? 'dangling' : 'unused'} images`,
|
||||
details: { danglingOnly, result }
|
||||
});
|
||||
// Audit log
|
||||
await audit(event, 'prune', 'image', {
|
||||
environmentId: envIdNum,
|
||||
description: `Pruned ${danglingOnly ? 'dangling' : 'unused'} images`,
|
||||
details: { danglingOnly, result }
|
||||
});
|
||||
|
||||
return json({ success: true, result });
|
||||
} catch (error) {
|
||||
console.error('Error pruning images:', error);
|
||||
return json({ error: 'Failed to prune images' }, { status: 500 });
|
||||
}
|
||||
send('result', { success: true, result });
|
||||
} catch (error) {
|
||||
console.error('Error pruning images:', error);
|
||||
send('result', { success: false, error: 'Failed to prune images' });
|
||||
}
|
||||
}, event.request);
|
||||
};
|
||||
@@ -340,7 +340,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,22 +1,22 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnContainerId, getHostDockerSocket } from '$lib/server/host-path';
|
||||
import { buildRegistryAuthHeader, unixSocketRequest, unixSocketStreamRequest } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prefersJSON, sseToJSON } from '$lib/server/sse';
|
||||
|
||||
const UPDATER_IMAGE = 'fnsys/dockhand-updater:latest';
|
||||
const UPDATER_LABEL = 'dockhand.updater';
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly.
|
||||
* Self-update always operates on the local engine — no environment routing needed.
|
||||
*/
|
||||
async function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
...options,
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
/** Fetch from the local Docker socket (buffered). */
|
||||
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
return unixSocketRequest(DOCKER_SOCKET, path, options);
|
||||
}
|
||||
|
||||
/** Fetch from the local Docker socket (streaming body for pull progress). */
|
||||
function localDockerStreamFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
return unixSocketStreamRequest(DOCKER_SOCKET, path, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,9 +34,10 @@ async function pullImageLocal(imageName: string, onProgress?: (line: string) =>
|
||||
}
|
||||
}
|
||||
|
||||
const response = await localDockerFetch(
|
||||
const authHeaders = await buildRegistryAuthHeader(imageName);
|
||||
const response = await localDockerStreamFetch(
|
||||
`/images/create?fromImage=${encodeURIComponent(fromImage)}&tag=${encodeURIComponent(tag)}`,
|
||||
{ method: 'POST' }
|
||||
{ method: 'POST', headers: authHeaders }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -400,11 +401,14 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
const sseResponse = new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
if (prefersJSON(request)) return sseToJSON(sseResponse);
|
||||
return sseResponse;
|
||||
};
|
||||
@@ -1,19 +1,15 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnContainerId } from '$lib/server/host-path';
|
||||
import { getRegistryManifestDigest } from '$lib/server/docker';
|
||||
import { getRegistryManifestDigest, unixSocketRequest } from '$lib/server/docker';
|
||||
import { compareVersions } from '$lib/utils/version';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly (not through environment routing)
|
||||
*/
|
||||
async function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
...options,
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
|
||||
/** Fetch from the local Docker socket directly (not through environment routing) */
|
||||
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
return unixSocketRequest(DOCKER_SOCKET, path, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +74,92 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Extract tag from image name
|
||||
const colonIdx = currentImage.lastIndexOf(':');
|
||||
const tag = colonIdx > -1 ? currentImage.substring(colonIdx + 1) : 'latest';
|
||||
const imageWithoutTag = colonIdx > -1 ? currentImage.substring(0, colonIdx) : currentImage;
|
||||
|
||||
// Check if this is a versioned tag (e.g., v1.0.18, 1.0.18, v1.0.18-baseline)
|
||||
const versionMatch = tag.match(/^(v?\d+\.\d+\.\d+)(-baseline)?$/);
|
||||
|
||||
if (versionMatch) {
|
||||
// Version-based check: compare against latest released version from changelog
|
||||
const currentTagVersion = versionMatch[1];
|
||||
const suffix = versionMatch[2] || ''; // '-baseline' or ''
|
||||
|
||||
try {
|
||||
const changelogResponse = await fetch(
|
||||
'https://raw.githubusercontent.com/Finsys/dockhand/main/src/lib/data/changelog.json',
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
|
||||
if (!changelogResponse.ok) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'Could not fetch changelog from GitHub'
|
||||
});
|
||||
}
|
||||
|
||||
const changelog = await changelogResponse.json() as Array<{
|
||||
version: string;
|
||||
comingSoon?: boolean;
|
||||
date?: string;
|
||||
changes?: Array<{ type: string; text: string }>;
|
||||
}>;
|
||||
|
||||
// Find latest released version (first entry without comingSoon)
|
||||
const latestRelease = changelog.find(entry => !entry.comingSoon);
|
||||
|
||||
if (!latestRelease) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'No released version found in changelog'
|
||||
});
|
||||
}
|
||||
|
||||
const latestVersion = latestRelease.version;
|
||||
const hasNewer = compareVersions(latestVersion, currentTagVersion) > 0;
|
||||
|
||||
if (hasNewer) {
|
||||
// Build new image tag preserving registry prefix and suffix
|
||||
const newTag = `v${latestVersion.replace(/^v/, '')}${suffix}`;
|
||||
const newImage = `${imageWithoutTag}:${newTag}`;
|
||||
|
||||
return json({
|
||||
updateAvailable: true,
|
||||
currentImage,
|
||||
newImage,
|
||||
latestVersion: latestVersion.replace(/^v/, ''),
|
||||
containerName,
|
||||
isComposeManaged
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged
|
||||
});
|
||||
} catch (err) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'Version check failed: ' + String(err)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Digest-based check for mutable tags (:latest, :baseline, etc.)
|
||||
|
||||
// Inspect image via local Docker socket to get RepoDigests
|
||||
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
|
||||
if (!imageResponse.ok) {
|
||||
@@ -105,6 +187,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
newImage: currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
isLocalImage: true
|
||||
@@ -117,6 +200,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
newImage: currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'Could not query registry'
|
||||
@@ -128,6 +212,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
return json({
|
||||
updateAvailable: hasUpdate,
|
||||
currentImage,
|
||||
newImage: currentImage,
|
||||
currentDigest: localDigests[0],
|
||||
newDigest: registryDigest,
|
||||
containerName,
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { unixSocketRequest } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly
|
||||
*/
|
||||
async function localDockerFetch(path: string): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
|
||||
/** Fetch from the local Docker socket directly */
|
||||
function localDockerFetch(path: string): Promise<Response> {
|
||||
return unixSocketRequest(DOCKER_SOCKET, path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSystemJobs } from '$lib/server/scheduler';
|
||||
import { sendToEventSubprocess, sendToMetricsSubprocess, type UpdateIntervalCommand } from '$lib/server/subprocess-manager';
|
||||
import { sendToEventSubprocess, sendToMetricsSubprocess } from '$lib/server/subprocess-manager';
|
||||
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listComposeStacks, deployStack, saveStackComposeFile, writeStackEnvFile, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks';
|
||||
import { EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { EnvironmentNotFoundError, DockerConnectionError } from '$lib/server/docker';
|
||||
import { upsertStackSource, getStackSources } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
@@ -62,8 +63,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
// Silently return empty for connection errors (offline environments)
|
||||
if (error instanceof DockerConnectionError) {
|
||||
return json([]);
|
||||
}
|
||||
console.error('Error listing compose stacks:', error);
|
||||
// Return empty array instead of error to allow UI to load
|
||||
return json([]);
|
||||
}
|
||||
};
|
||||
@@ -162,20 +166,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy and start the stack
|
||||
const result = await deployStack({
|
||||
name,
|
||||
compose,
|
||||
envId: envIdNum,
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return json({ error: result.error, output: result.output }, { status: 400 });
|
||||
}
|
||||
|
||||
// Record the stack as internally created with custom paths if provided
|
||||
// Record the stack in DB before deploying - ensures it exists even if deploy fails
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envIdNum,
|
||||
@@ -184,10 +175,31 @@ export const POST: RequestHandler = async (event) => {
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
// Audit log (create + deploy in one action)
|
||||
await auditStack(event, 'deploy', name, envIdNum);
|
||||
// Deploy via SSE to keep connection alive during long operations
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const result = await deployStack({
|
||||
name,
|
||||
compose,
|
||||
envId: envIdNum,
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
return json({ success: true, started: true, output: result.output });
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error, output: result.output });
|
||||
return;
|
||||
}
|
||||
|
||||
// Audit log (create + deploy in one action)
|
||||
await auditStack(event, 'deploy', name, envIdNum);
|
||||
|
||||
send('result', { success: true, started: true, output: result.output });
|
||||
} catch (error: any) {
|
||||
console.error('Error deploying compose stack:', error);
|
||||
send('result', { success: false, error: error.message || 'Failed to deploy stack' });
|
||||
}
|
||||
}, request);
|
||||
} catch (error: any) {
|
||||
console.error('Error creating compose stack:', error);
|
||||
return json({ error: error.message || 'Failed to create stack' }, { status: 500 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
// GET /api/stacks/[name]/compose - Get compose file content
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
@@ -66,7 +67,6 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
||||
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath }
|
||||
: undefined;
|
||||
|
||||
let result;
|
||||
if (restart) {
|
||||
// Deploy with docker compose up -d --force-recreate
|
||||
// Force recreate ensures env var changes are applied
|
||||
@@ -79,19 +79,34 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
||||
}
|
||||
// Get authoritative paths from DB/filesystem for deploy
|
||||
const composeInfo = await getStackComposeFile(name, envIdNum);
|
||||
result = await deployStack({
|
||||
name,
|
||||
compose: content,
|
||||
envId: envIdNum,
|
||||
forceRecreate: true,
|
||||
composePath: composeInfo.composePath || undefined,
|
||||
envPath: composeInfo.envPath || undefined
|
||||
});
|
||||
} else {
|
||||
// Just save the file without restarting (update operation, not create)
|
||||
result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
|
||||
|
||||
// Deploy via SSE to keep connection alive during long operations
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const result = await deployStack({
|
||||
name,
|
||||
compose: content,
|
||||
envId: envIdNum,
|
||||
forceRecreate: true,
|
||||
composePath: composeInfo.composePath || undefined,
|
||||
envPath: composeInfo.envPath || undefined
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true });
|
||||
} catch (error: any) {
|
||||
console.error(`Error deploying stack ${name}:`, error);
|
||||
send('result', { success: false, error: error.message || 'Failed to deploy stack' });
|
||||
}
|
||||
}, request);
|
||||
}
|
||||
|
||||
// Just save the file without restarting (update operation, not create)
|
||||
const result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ error: result.error }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { downStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -21,31 +22,35 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Parse body BEFORE creating SSE response (body can only be read once)
|
||||
let removeVolumes = false;
|
||||
try {
|
||||
// Parse body for optional removeVolumes flag
|
||||
let removeVolumes = false;
|
||||
try {
|
||||
const body = await request.json();
|
||||
removeVolumes = body.removeVolumes === true;
|
||||
} catch {
|
||||
// No body or invalid JSON - use defaults
|
||||
}
|
||||
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await downStack(stackName, envIdNum, removeVolumes);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'down', stackName, envIdNum, { removeVolumes });
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: result.error }, { status: 400 });
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
console.error('Error downing compose stack:', error);
|
||||
return json({ error: 'Failed to down compose stack' }, { status: 500 });
|
||||
const body = await request.json();
|
||||
removeVolumes = body.removeVolumes === true;
|
||||
} catch {
|
||||
// No body or invalid JSON - use defaults
|
||||
}
|
||||
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await downStack(stackName, envIdNum, removeVolumes);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'down', stackName, envIdNum, { removeVolumes });
|
||||
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
send('result', { success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Error downing compose stack:', error);
|
||||
send('result', { success: false, error: 'Failed to down compose stack' });
|
||||
}
|
||||
}, request);
|
||||
};
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { getStackEnvVars, setStackEnvVars, getStackSource } from '$lib/server/db';
|
||||
import { findStackDir } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
@@ -94,7 +94,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
// Internal/adopted stacks: non-secrets from file, secrets from DB
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
const content = await Bun.file(envFilePath).text();
|
||||
const content = readFileSync(envFilePath, 'utf-8');
|
||||
const fileVars = parseEnvFile(content);
|
||||
for (const [key, value] of Object.entries(fileVars)) {
|
||||
variables.push({ key, value, isSecret: false });
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { findStackDir, getStackDir } from '$lib/server/stacks';
|
||||
import { getStackSource } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { existsSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
@@ -58,7 +58,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let content = '';
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
content = await Bun.file(envFilePath).text();
|
||||
content = readFileSync(envFilePath, 'utf-8');
|
||||
} catch {
|
||||
// File read failed
|
||||
}
|
||||
@@ -154,7 +154,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
|
||||
content += '\n';
|
||||
}
|
||||
|
||||
await Bun.write(envFilePath, content);
|
||||
writeFileSync(envFilePath, content);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { restartStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -21,22 +22,26 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await restartStack(stackName, envIdNum);
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await restartStack(stackName, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'restart', stackName, envIdNum);
|
||||
// Audit log
|
||||
await auditStack(event, 'restart', stackName, envIdNum);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: result.error }, { status: 400 });
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
send('result', { success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Error restarting compose stack:', error);
|
||||
send('result', { success: false, error: 'Failed to restart compose stack' });
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
console.error('Error restarting compose stack:', error);
|
||||
return json({ error: 'Failed to restart compose stack' }, { status: 500 });
|
||||
}
|
||||
}, event.request);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { startStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -21,22 +22,26 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await startStack(stackName, envIdNum);
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await startStack(stackName, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'start', stackName, envIdNum);
|
||||
// Audit log
|
||||
await auditStack(event, 'start', stackName, envIdNum);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: result.error }, { status: 400 });
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
send('result', { success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Error starting compose stack:', error);
|
||||
send('result', { success: false, error: 'Failed to start compose stack' });
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
console.error('Error starting compose stack:', error);
|
||||
return json({ error: 'Failed to start compose stack' }, { status: 500 });
|
||||
}
|
||||
}, event.request);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { stopStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
@@ -21,22 +22,26 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await stopStack(stackName, envIdNum);
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await stopStack(stackName, envIdNum);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'stop', stackName, envIdNum);
|
||||
// Audit log
|
||||
await auditStack(event, 'stop', stackName, envIdNum);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: result.error }, { status: 400 });
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
send('result', { success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Error stopping compose stack:', error);
|
||||
send('result', { success: false, error: 'Failed to stop compose stack' });
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
console.error('Error stopping compose stack:', error);
|
||||
return json({ error: 'Failed to stop compose stack' }, { status: 500 });
|
||||
}
|
||||
}, event.request);
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
// Detect which stacks are already running on any environment
|
||||
const discoveredWithRunning = await detectRunningStacks(result.discovered);
|
||||
discoveredWithRunning.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return json({
|
||||
...result,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isPostgres, isSqlite, getDatabaseSchemaVersion, getPostgresConnectionIn
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
@@ -47,12 +48,12 @@ function detectContainerRuntime(): { inContainer: boolean; runtime?: string; con
|
||||
return { inContainer: false };
|
||||
}
|
||||
|
||||
// Get Bun runtime info
|
||||
function getBunInfo() {
|
||||
// Get runtime info
|
||||
function getRuntimeInfo() {
|
||||
const memUsage = process.memoryUsage();
|
||||
return {
|
||||
version: typeof Bun !== 'undefined' ? Bun.version : null,
|
||||
revision: typeof Bun !== 'undefined' ? Bun.revision?.slice(0, 7) : null,
|
||||
name: 'Node.js',
|
||||
version: process.version,
|
||||
memory: {
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
@@ -67,7 +68,6 @@ async function getOwnContainerInfo(containerId: string | undefined): Promise<any
|
||||
if (!containerId) return null;
|
||||
|
||||
try {
|
||||
// Try to inspect our own container via local socket
|
||||
const socketPaths = [
|
||||
'/var/run/docker.sock',
|
||||
process.env.DOCKER_SOCKET
|
||||
@@ -77,18 +77,33 @@ async function getOwnContainerInfo(containerId: string | undefined): Promise<any
|
||||
if (!socketPath || !existsSync(socketPath)) continue;
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://localhost/containers/${containerId}/json`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: socketPath
|
||||
const info = await new Promise<any>((resolve, reject) => {
|
||||
const req = http.request({
|
||||
socketPath,
|
||||
path: `/containers/${containerId}/json`,
|
||||
method: 'GET',
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
res.on('error', () => resolve(null));
|
||||
});
|
||||
req.on('error', () => resolve(null));
|
||||
req.end();
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const info = await response.json();
|
||||
if (info) {
|
||||
return {
|
||||
id: info.Id?.slice(0, 12),
|
||||
name: info.Name?.replace(/^\//, ''),
|
||||
image: info.Config?.Image,
|
||||
imageId: info.Image?.slice(7, 19), // Remove 'sha256:' prefix
|
||||
imageId: info.Image?.slice(7, 19),
|
||||
created: info.Created,
|
||||
status: info.State?.Status,
|
||||
restartCount: info.RestartCount,
|
||||
@@ -153,7 +168,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const runningContainers = containers.filter(c => c.state === 'running').length;
|
||||
const stoppedContainers = containers.length - runningContainers;
|
||||
|
||||
const bunInfo = getBunInfo();
|
||||
const runtimeInfo = getRuntimeInfo();
|
||||
const containerRuntime = detectContainerRuntime();
|
||||
const ownContainer = containerRuntime.inContainer
|
||||
? await getOwnContainerInfo(containerRuntime.containerId || os.hostname())
|
||||
@@ -181,13 +196,13 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
storageDriver: dockerInfo.Driver
|
||||
} : null,
|
||||
runtime: {
|
||||
bun: bunInfo.version,
|
||||
bunRevision: bunInfo.revision,
|
||||
nodeVersion: process.version,
|
||||
runtimeName: runtimeInfo.name,
|
||||
runtimeVersion: runtimeInfo.version,
|
||||
nodeVersion: runtimeInfo.version,
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
kernel: os.release(),
|
||||
memory: bunInfo.memory,
|
||||
memory: runtimeInfo.memory,
|
||||
container: containerRuntime,
|
||||
ownContainer
|
||||
},
|
||||
|
||||
@@ -114,7 +114,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Auto-login if this is the first user being created (and auth is enabled)
|
||||
let autoLoggedIn = false;
|
||||
if (isFirstUser && auth.authEnabled) {
|
||||
await createUserSession(user.id, 'local', cookies);
|
||||
await createUserSession(user.id, 'local', cookies, event.request);
|
||||
autoLoggedIn = true;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
name: error.name,
|
||||
stack: error.stack
|
||||
});
|
||||
if (error.message?.includes('UNIQUE constraint failed') || error.code === '23505') {
|
||||
if (error.message?.includes('UNIQUE constraint failed') || error.code === '23505' || (error as any).cause?.code === '23505') {
|
||||
return json({ error: 'Username already exists' }, { status: 409 });
|
||||
}
|
||||
return json({ error: 'Failed to create user', details: error.message }, { status: 500 });
|
||||
|
||||
@@ -229,7 +229,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update user:', error);
|
||||
if (error.message?.includes('UNIQUE constraint failed')) {
|
||||
if (error.message?.includes('UNIQUE constraint failed') || (error as any).cause?.code === '23505') {
|
||||
return json({ error: 'Username already exists' }, { status: 409 });
|
||||
}
|
||||
return json({ error: 'Failed to update user' }, { status: 500 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listVolumes, createVolume, EnvironmentNotFoundError, type CreateVolumeOptions } from '$lib/server/docker';
|
||||
import { listVolumes, createVolume, EnvironmentNotFoundError, DockerConnectionError, type CreateVolumeOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditVolume } from '$lib/server/audit';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
@@ -33,7 +33,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Failed to list volumes:', error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error('Failed to list volumes:', error);
|
||||
}
|
||||
return json({ error: 'Failed to list volumes' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getVolumeArchive } from '$lib/server/docker';
|
||||
@@ -31,9 +32,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
|
||||
|
||||
if (format === 'tar.gz') {
|
||||
// Compress with gzip using Bun's native implementation
|
||||
// Compress with gzip
|
||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||
body = Bun.gzipSync(tarData);
|
||||
body = gzipSync(tarData);
|
||||
contentType = 'application/gzip';
|
||||
extension = '.tar.gz';
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user