feat: add Docker service for container management and image pulls
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user