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 { fileURLToPath } from "url";
|
||||||
import { initWebSocket, getClientCount } from "./services/ws.service.js";
|
import { initWebSocket, getClientCount } from "./services/ws.service.js";
|
||||||
import workspaceRouter from "./routes/workspace.js";
|
import workspaceRouter from "./routes/workspace.js";
|
||||||
|
import dockerRouter from "./routes/docker.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -24,6 +25,7 @@ app.get("/api/health", (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use("/api/workspace", workspaceRouter);
|
app.use("/api/workspace", workspaceRouter);
|
||||||
|
app.use("/api/docker", dockerRouter);
|
||||||
|
|
||||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||||
app.use(express.static(frontendDist));
|
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