diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 6cc74c5..ae11f7e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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)); diff --git a/packages/backend/src/routes/docker.ts b/packages/backend/src/routes/docker.ts new file mode 100644 index 0000000..6a7f110 --- /dev/null +++ b/packages/backend/src/routes/docker.ts @@ -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; diff --git a/packages/backend/src/services/docker.service.ts b/packages/backend/src/services/docker.service.ts new file mode 100644 index 0000000..32e047a --- /dev/null +++ b/packages/backend/src/services/docker.service.ts @@ -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 { + 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 { + broadcast("docker", "pull:start", { image: imageName }); + const stream = await docker.pull(imageName); + await new Promise((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 { + const container = docker.getContainer(name); + await container.start(); + broadcast("docker", "container:started", { name }); +} + +export async function stopContainer(name: string): Promise { + const container = docker.getContainer(name); + await container.stop(); + broadcast("docker", "container:stopped", { name }); +} + +export async function restartContainer(name: string): Promise { + const container = docker.getContainer(name); + await container.restart(); + broadcast("docker", "container:restarted", { name }); +} + +export async function getContainerLogs( + name: string, + tail: number = 100, +): Promise { + 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 { + 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; +}