Deploy MVP

This commit is contained in:
2026-01-02 23:35:40 +01:00
parent f9bbfc7f0a
commit 555b94b9b2
5 changed files with 220 additions and 28 deletions
+6 -7
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth"; import testAuth from "../auth";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { getDeployments } from "@/core/deployment"; import { deployApp, getDeployments } from "@/core/deployment";
import { getApplications } from "@/core/application"; import { getApplications } from "@/core/application";
export async function GET(req: NextRequest): Promise<NextResponse> { export async function GET(req: NextRequest): Promise<NextResponse> {
@@ -26,8 +26,7 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
export async function POST(req: NextRequest): Promise<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req); const auth = await testAuth(req);
const { name, repo, nodeId }: { name: string; repo: string; nodeId: string } = const { name, appId }: { name: string; appId: string } = await req.json();
await req.json();
if (auth) { if (auth) {
return NextResponse.json( return NextResponse.json(
@@ -36,17 +35,17 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
); );
} }
if (!name || !repo || !nodeId) { if (!name || !appId) {
return NextResponse.json( return NextResponse.json(
{ message: "Body missing name, repo, nodeId" }, { message: "Body missing name, appId, nodeId" },
{ status: StatusCodes.BAD_REQUEST } { status: StatusCodes.BAD_REQUEST }
); );
} }
const res = await installApp(name, repo, nodeId); const deployId = await deployApp(name, appId);
return NextResponse.json({ return NextResponse.json({
message: "ok", message: "ok",
...res, deployId,
}); });
} }
+7 -4
View File
@@ -17,6 +17,7 @@ import { Application, Deployment } from "@/lib/types";
import { getDeployments } from "@/lib/dockship/deploy"; import { getDeployments } from "@/lib/dockship/deploy";
import { getApplications } from "@/lib/dockship/application"; import { getApplications } from "@/lib/dockship/application";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import DeployAppDialog from "@/components/dialogs/DeployApp";
export default function DeploymentsPage() { export default function DeploymentsPage() {
const [applications, setApplications] = useState<Application[]>([]); const [applications, setApplications] = useState<Application[]>([]);
@@ -39,10 +40,12 @@ export default function DeploymentsPage() {
<div className="flex flex-row gap-3 items-center justify-between mb-6"> <div className="flex flex-row gap-3 items-center justify-between mb-6">
<Badge>{deployments.length} deployments</Badge> <Badge>{deployments.length} deployments</Badge>
<Button size="sm" className="gap-2 self-auto"> <DeployAppDialog>
<Plus className="w-4 h-4" /> <Button size="sm" className="gap-2 self-auto">
New Deployment <Plus className="w-4 h-4" />
</Button> New Deployment
</Button>
</DeployAppDialog>
</div> </div>
<div className="hidden md:visible space-y-3"> <div className="hidden md:visible space-y-3">
+108
View File
@@ -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<Application[]>([]);
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 (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-106.25">
<DialogHeader>
<DialogTitle>Deploy Application</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="name">
Deployment name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My app"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="repo">
Application <span className="text-destructive">*</span>
</Label>
<Combobox
value={app}
setValue={setApp}
data={apps.map((app) => ({
label: app.name,
value: app.id,
}))}
placeholder="Select App..."
listPlaceholder="No apps found :("
/>
</div>
<div className="grid gap-3">
<Label htmlFor="node">
Node <span className="text-destructive">*</span>
</Label>
<Combobox
value={node}
setValue={setNode}
data={nodeList
.filter((node) => node.liveData)
.map((node) => ({ label: node.name, value: node.node_id }))}
placeholder="Select Node..."
listPlaceholder="No nodes found :("
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={deploy}>Deploy</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+77 -17
View File
@@ -3,51 +3,111 @@
import Docker from "dockerode"; import Docker from "dockerode";
import { Application, Deployment } from "@/lib/types"; import { Application, Deployment } from "@/lib/types";
import { CPUusage } from "@/lib/server/calc"; import { CPUusage } from "@/lib/server/calc";
import path from "path";
import fs from "fs";
const docker = new Docker(); 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( export async function getDeployments(
apps: Application[] apps: Application[]
): Promise<(null | Deployment)[]> { ): Promise<(null | Deployment)[]> {
const containers = await docker.listContainers({ all: true });
return await Promise.all( return await Promise.all(
apps apps
.map((app) => app.deployments) .map((app) => app.deployments)
.flat() .flat()
.map(async (imageId) => { .map(async (deployId) => {
const containerInfo = containers.find( const container = docker.getContainer(deployId);
(container) => container.ImageID === imageId const containerInfo = await container.inspect();
);
if (!containerInfo) return null;
const container = docker.getContainer(containerInfo.Id);
let cpu = 0; let cpu = 0;
let memory = 0; let memory = 0;
let ports: Set<[number, number]> = new Set(); let ports: Set<[number, number]> = new Set();
if (containerInfo.State === "running") { if (containerInfo.State.Running) {
const stats = await container.stats({ stream: false }); const stats = await container.stats({ stream: false });
memory += (stats.memory_stats.usage / stats.memory_stats.limit) * 100; memory += (stats.memory_stats.usage / stats.memory_stats.limit) * 100;
cpu += CPUusage(stats); cpu += CPUusage(stats);
} }
containerInfo.Ports?.forEach((p) => { for (const [key, mappings] of Object.entries(
if (p.PublicPort && p.PrivatePort) { containerInfo.NetworkSettings.Ports
ports.add([p.PublicPort, p.PrivatePort]); )) {
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 { return {
image: containerInfo.ImageID, image: containerInfo.Image,
container: containerInfo.Id, container: containerInfo.Id,
cpu, cpu,
memory, memory,
status: containerInfo.State === "running" ? "running" : "stopped", status: containerInfo.State.Running ? "running" : "stopped",
ports: [...ports], ports: [...ports],
}; };
}) })
); );
} }
export async function deployApp(
name: string,
appId: string
): Promise<string | undefined> {
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<void>((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;
}
}
+22
View File
@@ -20,3 +20,25 @@ export async function getDeployments(): Promise<(null | Deployment)[]> {
) )
).flat(); ).flat();
} }
export async function deployApp(
name: string,
appId: string,
nodeId: string
): Promise<string | undefined> {
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;
}