import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { inspectImage, tagImage, pushImage } from '$lib/server/docker'; 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'; /** * Check if environment is edge mode */ async function isEdgeMode(envId?: number): Promise<{ isEdge: boolean; environmentId?: number }> { if (!envId) { return { isEdge: false }; } const env = await getEnvironment(envId); if (env?.connectionType === 'hawser-edge') { return { isEdge: true, environmentId: envId }; } return { isEdge: false }; } export const POST: RequestHandler = async (event) => { const { request, url, cookies } = event; const auth = await authorize(cookies); const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; // Permission check with environment context if (auth.authEnabled && !await auth.can('images', 'push', envIdNum)) { return json({ error: 'Permission denied' }, { status: 403 }); } try { const { imageId, imageName, registryId, newTag } = await request.json(); if (!imageId || !registryId) { return json({ error: 'Image ID and registry ID are required' }, { status: 400 }); } const registry = await getRegistry(registryId); if (!registry) { return json({ error: 'Registry not found' }, { status: 404 }); } // Get the image info const imageInfo = await inspectImage(imageId, envIdNum) as any; // Determine the source tag to use let sourceTag = imageName; if (!sourceTag && imageInfo.RepoTags && imageInfo.RepoTags.length > 0) { sourceTag = imageInfo.RepoTags[0]; } if (!sourceTag || sourceTag === ':') { return json({ error: 'Image has no tag. Please provide a tag name.' }, { status: 400 }); } // Extract just the image name (without registry prefix if any) let baseImageName = sourceTag; // Remove any existing registry prefix (e.g., "registry.example.com/myimage:tag" -> "myimage:tag") if (baseImageName.includes('/')) { const parts = baseImageName.split('/'); // Check if first part looks like a registry (contains . or :) if (parts[0].includes('.') || parts[0].includes(':')) { baseImageName = parts.slice(1).join('/'); } } // Build the target tag const registryUrl = new URL(registry.url); const registryHost = registryUrl.host; // Check if this is Docker Hub const isDockerHub = registryHost.includes('docker.io') || registryHost.includes('hub.docker.com') || registryHost.includes('registry.hub.docker.com') || registryHost.includes('index.docker.io'); // Use custom tag if provided, otherwise use the base image name const targetImageName = newTag || baseImageName; // Docker Hub doesn't need host prefix - just username/image:tag const targetTag = isDockerHub ? targetImageName : `${registryHost}/${targetImageName}`; // Parse repo and tag properly (handle registry:port/image:tag format) // Find the last colon that's after the last slash (that's the tag separator) const lastSlashIndex = targetTag.lastIndexOf('/'); const tagPart = targetTag.substring(lastSlashIndex + 1); const colonInTagIndex = tagPart.lastIndexOf(':'); let repo: string; let tag: string; if (colonInTagIndex !== -1) { // Tag exists after the last slash repo = targetTag.substring(0, lastSlashIndex + 1 + colonInTagIndex); tag = tagPart.substring(colonInTagIndex + 1); } else { // No tag, use 'latest' repo = targetTag; tag = 'latest'; } // Prepare auth config // Docker Hub uses index.docker.io/v1 for auth const authServerAddress = isDockerHub ? 'https://index.docker.io/v1/' : registryHost; const authConfig = registry.username && registry.password ? { username: registry.username, password: registry.password, serveraddress: authServerAddress } : { serveraddress: authServerAddress }; // 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; let heartbeatInterval: ReturnType | 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'; if (error.statusCode === 401 || errorMessage.includes('401')) { userMessage = 'Authentication failed. Check registry credentials.'; } else if (error.statusCode === 404 || errorMessage.includes('404')) { userMessage = 'Image not found'; } else if (errorMessage.includes('https') || errorMessage.includes('tls') || errorMessage.includes('certificate') || errorMessage.includes('x509')) { userMessage = `TLS/HTTPS error. If your registry uses HTTP, add it to Docker's insecure-registries in /etc/docker/daemon.json`; } return userMessage; }; const stream = new ReadableStream({ async start(ctrl) { controller = ctrl; // Start heartbeat to keep connection alive through Traefik (10s idle timeout) heartbeatInterval = setInterval(() => { safeEnqueue(`: keepalive\n\n`); }, 5000); try { // Send tagging status safeEnqueue(`data: ${JSON.stringify({ status: 'tagging', message: 'Tagging image...' })}\n\n`); // 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 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 } } }, 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(); } }, 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(); } }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' } }); } catch (error: any) { console.error('Error setting up push:', error); return json({ error: error.message || 'Failed to push image' }, { status: 500 }); } };