"use server"; import Docker from "dockerode"; import { Application, Deployment, Port } from "@/lib/types"; import { CPUusage } from "@/lib/server/calc"; import path from "path"; import fs from "fs"; import pLimit from "p-limit"; import { randomUUID } from "crypto"; import { cloneRepo } from "./git"; import tar from "tar-fs"; const docker = new Docker(); function sanitizeString(str: string): string { if (!str) return ""; return ( str[0].replace(/[^a-zA-Z0-9]/g, "-") + str.slice(1).replace(/[^a-zA-Z0-9_.-]/g, "-") ); } function formatPorts(ports: Port[]) { const dockerPorts: Record = {}; ports.forEach(({ containerPort, hostPort, protocol }) => { const key = `${containerPort}/${protocol}`; if (!dockerPorts[key]) { dockerPorts[key] = []; } dockerPorts[key].push({ HostPort: hostPort }); }); return dockerPorts; } export async function getDeployments( apps: Application[], ): Promise { const limit = pLimit(5); const deploymentIds = apps.flatMap((app) => app.deployments); const results = await Promise.all( deploymentIds.map((deployId) => limit(async (): Promise => { try { const container = docker.getContainer(deployId); const containerInfo = await container.inspect(); let cpu = 0; let memory = 0; // Use Map to deduplicate ports reliably const ports = new Map(); if (containerInfo.State?.Running) { const stats = await container.stats({ stream: false }); const usage = stats.memory_stats?.usage ?? 0; const limitMem = stats.memory_stats?.limit ?? 0; if (limitMem > 0) { memory = (usage / limitMem) * 100; } cpu = CPUusage(stats); } const portMappings = containerInfo.NetworkSettings?.Ports ?? {}; for (const [key, mappings] of Object.entries(portMappings)) { if (!mappings) continue; const privatePort = Number.parseInt(key.split("/")[0], 10); for (const mapping of mappings) { const publicPort = Number.parseInt(mapping.HostPort, 10); if (!Number.isNaN(publicPort) && !Number.isNaN(privatePort)) { ports.set(publicPort, privatePort); } } } return { name: containerInfo.Name.replace(/^\//, ""), image: containerInfo.Image, container: containerInfo.Id, cpu, memory, status: containerInfo.State?.Running ? "running" : "stopped", ports: [...ports.entries()].map( ([publicPort, privatePort]) => [publicPort, privatePort] as [number, number], ), }; } catch { // Container may have stopped / been removed return null; } }), ), ); // Remove failed containers return results.filter( (deployment: any): deployment is Deployment => deployment !== null, ); } export async function deployApp( name: string, app: Application, ports: Port[], ): Promise { const deployId = randomUUID(); const deployPath = path.join("data", "deploys", deployId); const imageTag = `dockship/${deployId}:latest`; // Clone the repo await cloneRepo(app.repo, deployPath); try { // Check if app exists fs.accessSync(deployPath); } catch { console.error("Repo does not exist:", deployPath); return undefined; } try { const stream = await docker.buildImage(tar.pack(deployPath), { t: imageTag, }); await new Promise((resolve, reject) => { docker.modem.followProgress(stream, (err: Error | null) => err ? reject(err) : resolve(), ); }); const container = await docker.createContainer({ Image: imageTag, name: sanitizeString(name), Tty: true, HostConfig: { RestartPolicy: { Name: "unless-stopped" }, PortBindings: formatPorts(ports), }, Env: Object.entries(app.env).map( ([key, { value }]) => `${key}="${value.replaceAll('"', '\\"')}"`, ), }); await container.start(); await docker.pruneImages(); return container.id; } catch (err) { console.error("Failed to deploy app:", err); return undefined; } finally { fs.rmSync(deployPath, { recursive: true, force: true }); } } export async function toggleDeployment( containerId: string, action: "start" | "stop", ) { const container = docker.getContainer(containerId); try { if (action === "start") { await container.start(); } else { await container.stop(); } } catch (err) { console.error(`Failed to ${action} container ${containerId}:`, err); } }