feat: add Docker service for container management and image pulls

This commit is contained in:
plenarius
2026-04-20 18:29:09 -04:00
parent ee7a0783ea
commit ec8aaf64a3
3 changed files with 210 additions and 0 deletions
+2
View File
@@ -5,6 +5,7 @@ import path from "path";
import { fileURLToPath } from "url";
import { initWebSocket, getClientCount } from "./services/ws.service.js";
import workspaceRouter from "./routes/workspace.js";
import dockerRouter from "./routes/docker.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
@@ -24,6 +25,7 @@ app.get("/api/health", (_req, res) => {
});
app.use("/api/workspace", workspaceRouter);
app.use("/api/docker", dockerRouter);
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
app.use(express.static(frontendDist));
+70
View File
@@ -0,0 +1,70 @@
import { Router } from "express";
import {
listForgeContainers,
pullImage,
startContainer,
stopContainer,
restartContainer,
getContainerLogs,
} from "../services/docker.service.js";
const router = Router();
router.get("/containers", async (_req, res) => {
try {
const containers = await listForgeContainers();
res.json(containers);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post("/pull", async (req, res) => {
const { image } = req.body;
if (!image) return res.status(400).json({ error: "image required" });
try {
await pullImage(image);
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post("/containers/:name/start", async (req, res) => {
try {
await startContainer(req.params.name);
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post("/containers/:name/stop", async (req, res) => {
try {
await stopContainer(req.params.name);
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post("/containers/:name/restart", async (req, res) => {
try {
await restartContainer(req.params.name);
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.get("/containers/:name/logs", async (req, res) => {
const tail = parseInt(req.query.tail as string) || 100;
try {
const logs = await getContainerLogs(req.params.name, tail);
res.json({ logs });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;
@@ -0,0 +1,138 @@
import Docker from "dockerode";
import { broadcast } from "./ws.service.js";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
export interface ContainerInfo {
id: string;
name: string;
image: string;
state: string;
status: string;
health?: string;
}
export async function listForgeContainers(): Promise<ContainerInfo[]> {
const containers = await docker.listContainers({ all: true });
return containers
.filter((c) => c.Names.some((n) => n.startsWith("/layonara-")))
.map((c) => ({
id: c.Id.slice(0, 12),
name: c.Names[0].replace(/^\//, ""),
image: c.Image,
state: c.State,
status: c.Status,
health: c.Status.includes("healthy")
? "healthy"
: c.Status.includes("unhealthy")
? "unhealthy"
: undefined,
}));
}
export async function pullImage(imageName: string): Promise<void> {
broadcast("docker", "pull:start", { image: imageName });
const stream = await docker.pull(imageName);
await new Promise<void>((resolve, reject) => {
docker.modem.followProgress(
stream,
(err: Error | null) => {
if (err) {
broadcast("docker", "pull:error", { image: imageName, error: err.message });
reject(err);
} else {
broadcast("docker", "pull:complete", { image: imageName });
resolve();
}
},
(event: { status: string; progress?: string }) => {
broadcast("docker", "pull:progress", {
image: imageName,
status: event.status,
progress: event.progress,
});
},
);
});
}
export async function startContainer(name: string): Promise<void> {
const container = docker.getContainer(name);
await container.start();
broadcast("docker", "container:started", { name });
}
export async function stopContainer(name: string): Promise<void> {
const container = docker.getContainer(name);
await container.stop();
broadcast("docker", "container:stopped", { name });
}
export async function restartContainer(name: string): Promise<void> {
const container = docker.getContainer(name);
await container.restart();
broadcast("docker", "container:restarted", { name });
}
export async function getContainerLogs(
name: string,
tail: number = 100,
): Promise<string> {
const container = docker.getContainer(name);
const logs = await container.logs({
stdout: true,
stderr: true,
tail,
timestamps: true,
});
return logs.toString();
}
export async function streamContainerLogs(name: string): Promise<NodeJS.ReadableStream> {
const container = docker.getContainer(name);
return container.logs({
follow: true,
stdout: true,
stderr: true,
tail: 50,
timestamps: true,
});
}
export async function runEphemeralContainer(opts: {
image: string;
cmd: string[];
binds: string[];
workingDir?: string;
env?: string[];
}): Promise<{ statusCode: number; output: string }> {
const container = await docker.createContainer({
Image: opts.image,
Cmd: opts.cmd,
WorkingDir: opts.workingDir,
Env: opts.env,
HostConfig: {
Binds: opts.binds,
AutoRemove: true,
},
AttachStdout: true,
AttachStderr: true,
});
const stream = await container.attach({ stream: true, stdout: true, stderr: true });
let output = "";
stream.on("data", (chunk: Buffer) => {
const text = chunk.toString();
output += text;
broadcast("build", "output", { text });
});
await container.start();
const { StatusCode } = await container.wait();
return { statusCode: StatusCode, output };
}
export function getDockerClient(): Docker {
return docker;
}