From 555b94b9b28691deafbb366ba3383a9a4ff87248 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Fri, 2 Jan 2026 23:35:40 +0100 Subject: [PATCH] Deploy MVP --- src/app/api/deploy/route.ts | 13 ++-- src/app/deployments/page.tsx | 11 ++- src/components/dialogs/DeployApp.tsx | 108 +++++++++++++++++++++++++++ src/core/deployment.ts | 94 ++++++++++++++++++----- src/lib/dockship/deploy.ts | 22 ++++++ 5 files changed, 220 insertions(+), 28 deletions(-) create mode 100644 src/components/dialogs/DeployApp.tsx diff --git a/src/app/api/deploy/route.ts b/src/app/api/deploy/route.ts index 4506d48..6898a0c 100644 --- a/src/app/api/deploy/route.ts +++ b/src/app/api/deploy/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; -import { getDeployments } from "@/core/deployment"; +import { deployApp, getDeployments } from "@/core/deployment"; import { getApplications } from "@/core/application"; export async function GET(req: NextRequest): Promise { @@ -26,8 +26,7 @@ export async function GET(req: NextRequest): Promise { export async function POST(req: NextRequest): Promise { const auth = await testAuth(req); - const { name, repo, nodeId }: { name: string; repo: string; nodeId: string } = - await req.json(); + const { name, appId }: { name: string; appId: string } = await req.json(); if (auth) { return NextResponse.json( @@ -36,17 +35,17 @@ export async function POST(req: NextRequest): Promise { ); } - if (!name || !repo || !nodeId) { + if (!name || !appId) { return NextResponse.json( - { message: "Body missing name, repo, nodeId" }, + { message: "Body missing name, appId, nodeId" }, { status: StatusCodes.BAD_REQUEST } ); } - const res = await installApp(name, repo, nodeId); + const deployId = await deployApp(name, appId); return NextResponse.json({ message: "ok", - ...res, + deployId, }); } diff --git a/src/app/deployments/page.tsx b/src/app/deployments/page.tsx index 88efa13..382185e 100644 --- a/src/app/deployments/page.tsx +++ b/src/app/deployments/page.tsx @@ -17,6 +17,7 @@ import { Application, Deployment } from "@/lib/types"; import { getDeployments } from "@/lib/dockship/deploy"; import { getApplications } from "@/lib/dockship/application"; import { cn } from "@/lib/utils"; +import DeployAppDialog from "@/components/dialogs/DeployApp"; export default function DeploymentsPage() { const [applications, setApplications] = useState([]); @@ -39,10 +40,12 @@ export default function DeploymentsPage() {
{deployments.length} deployments - + + +
diff --git a/src/components/dialogs/DeployApp.tsx b/src/components/dialogs/DeployApp.tsx new file mode 100644 index 0000000..541c5ec --- /dev/null +++ b/src/components/dialogs/DeployApp.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState, ReactNode, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { getLiveNodes } from "@/lib/dockship/node"; +import { Combobox } from "../ui/combobox"; +import { Application, Node, NodeLiveData } from "@/lib/types"; +import { getApplications } from "@/core/application"; +import { deployApp } from "@/lib/dockship/deploy"; + +export default function DeployAppDialog({ children }: { children: ReactNode }) { + const [nodeList, setNodeList] = useState<(NodeLiveData & Node)[]>([]); + const [apps, setApps] = useState([]); + + const [name, setName] = useState(""); + const [app, setApp] = useState(""); + const [node, setNode] = useState(""); + + useEffect(() => { + const fetchNodes = async () => { + setNodeList(await getLiveNodes()); + setApps(await getApplications()); + }; + fetchNodes(); + }, []); + + const deploy = async () => { + console.log(name, app, node); + await deployApp(name, app, node); + }; + + return ( + + {children} + + + + Deploy Application + + +
+
+ + setName(e.target.value)} + placeholder="My app" + /> +
+ +
+ + ({ + label: app.name, + value: app.id, + }))} + placeholder="Select App..." + listPlaceholder="No apps found :(" + /> +
+ +
+ + node.liveData) + .map((node) => ({ label: node.name, value: node.node_id }))} + placeholder="Select Node..." + listPlaceholder="No nodes found :(" + /> +
+
+ + + + + + + + + +
+
+ ); +} diff --git a/src/core/deployment.ts b/src/core/deployment.ts index 30284cd..60641f0 100644 --- a/src/core/deployment.ts +++ b/src/core/deployment.ts @@ -3,51 +3,111 @@ import Docker from "dockerode"; import { Application, Deployment } from "@/lib/types"; import { CPUusage } from "@/lib/server/calc"; +import path from "path"; +import fs from "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, "-") + ); +} + export async function getDeployments( apps: Application[] ): Promise<(null | Deployment)[]> { - const containers = await docker.listContainers({ all: true }); - return await Promise.all( apps .map((app) => app.deployments) .flat() - .map(async (imageId) => { - const containerInfo = containers.find( - (container) => container.ImageID === imageId - ); - - if (!containerInfo) return null; - - const container = docker.getContainer(containerInfo.Id); + .map(async (deployId) => { + const container = docker.getContainer(deployId); + const containerInfo = await container.inspect(); let cpu = 0; let memory = 0; let ports: Set<[number, number]> = new Set(); - if (containerInfo.State === "running") { + if (containerInfo.State.Running) { const stats = await container.stats({ stream: false }); memory += (stats.memory_stats.usage / stats.memory_stats.limit) * 100; cpu += CPUusage(stats); } - containerInfo.Ports?.forEach((p) => { - if (p.PublicPort && p.PrivatePort) { - ports.add([p.PublicPort, p.PrivatePort]); + for (const [key, mappings] of Object.entries( + containerInfo.NetworkSettings.Ports + )) { + const privatePort = parseInt(key.split("/")[0], 10); + if (mappings) { + for (const mapping of mappings) { + const publicPort = parseInt(mapping.HostPort, 10); + ports.add([publicPort, privatePort]); + } } - }); + } return { - image: containerInfo.ImageID, + image: containerInfo.Image, container: containerInfo.Id, cpu, memory, - status: containerInfo.State === "running" ? "running" : "stopped", + status: containerInfo.State.Running ? "running" : "stopped", ports: [...ports], }; }) ); } + +export async function deployApp( + name: string, + appId: string +): Promise { + const appPath = path.join("data", "apps", appId, "repo"); + const imageTag = `dockship/${appId}:latest`; + + try { + // Check if app exists + fs.accessSync(appPath); + } catch { + console.error("Repo does not exist:", appPath); + return undefined; + } + + try { + const stream = await docker.buildImage( + { + context: appPath, + src: ["Dockerfile", "."], + }, + { t: imageTag } + ); + + await new Promise((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: { + "3000/tcp": [{ HostPort: "3800" }], + }, + }, + }); + + await container.start(); + + return container.id; + } catch (err) { + console.error("Failed to deploy app:", err); + return undefined; + } +} diff --git a/src/lib/dockship/deploy.ts b/src/lib/dockship/deploy.ts index 5176b54..0f6155e 100644 --- a/src/lib/dockship/deploy.ts +++ b/src/lib/dockship/deploy.ts @@ -20,3 +20,25 @@ export async function getDeployments(): Promise<(null | Deployment)[]> { ) ).flat(); } + +export async function deployApp( + name: string, + appId: string, + nodeId: string +): Promise { + const nodes = await getNodes(); + + const node = nodes.find((n) => n.node_id === nodeId); + + if (!node) throw new Error("Node not found"); + + return await ( + await axios.post( + `http://${node.ip}:3000/api/deploy`, + { name, appId }, + { + headers: { Authorization: `ApiKey ${node.key}` }, + } + ) + ).data.deployId; +}