185 lines
4.8 KiB
TypeScript
185 lines
4.8 KiB
TypeScript
"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<string, { HostPort: string }[]> = {};
|
|
|
|
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<Deployment[]> {
|
|
const limit = pLimit(5);
|
|
|
|
const deploymentIds = apps.flatMap((app) => app.deployments);
|
|
|
|
const results = await Promise.all(
|
|
deploymentIds.map((deployId) =>
|
|
limit(async (): Promise<Deployment | null> => {
|
|
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<number, number>();
|
|
|
|
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<string | undefined> {
|
|
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<void>((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);
|
|
}
|
|
}
|