From 2fdb5fbbe0fe9c69a3c8641a758b1fc2e91bead7 Mon Sep 17 00:00:00 2001 From: Peter Gonda Date: Tue, 1 Sep 2026 12:05:42 +0200 Subject: [PATCH] feat(nomad): per-server config + one-click bootstrap + installer - Nomad dashboard router resolves per-server address/token/namespace from the server's DB columns (127.0.0.1 fallback for the local cluster), with an org-ownership auth check; transport isolated in nomadClient() so an SSH tunnel can drop in later. Dashboard gains a cluster selector. - nomad.bootstrapServer streams a Docker+Consul+Nomad+CNI install over SSH and auto-saves the server's Nomad address on success. Surfaced as a 'Bootstrap Nomad' button in per-server Nomad settings (wired into the server dropdown). - install.sh: one-line control-plane installer for a fresh server. Co-Authored-By: Claude Opus 4.8 --- .../allocations/show-nomad-allocations.tsx | 31 +- .../dashboard/nomad/jobs/show-nomad-jobs.tsx | 52 ++- .../dashboard/nomad/logs/show-nomad-logs.tsx | 18 +- .../nomad/nodes/show-nomad-nodes.tsx | 55 +++- .../components/dashboard/nomad/overview.tsx | 30 +- .../settings/servers/nomad-settings-modal.tsx | 42 +++ .../settings/servers/nomad-settings.tsx | 62 +++- .../settings/servers/show-servers.tsx | 4 + apps/dokploy/pages/dashboard/nomad.tsx | 56 +++- apps/dokploy/server/api/routers/nomad.ts | 305 ++++++++++++------ install.sh | 195 +++++++++++ packages/server/src/setup/nomad-bootstrap.ts | 190 +++++++++++ 12 files changed, 880 insertions(+), 160 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/servers/nomad-settings-modal.tsx create mode 100755 install.sh create mode 100644 packages/server/src/setup/nomad-bootstrap.ts diff --git a/apps/dokploy/components/dashboard/nomad/allocations/show-nomad-allocations.tsx b/apps/dokploy/components/dashboard/nomad/allocations/show-nomad-allocations.tsx index 0e62161ff..0b6814a4d 100644 --- a/apps/dokploy/components/dashboard/nomad/allocations/show-nomad-allocations.tsx +++ b/apps/dokploy/components/dashboard/nomad/allocations/show-nomad-allocations.tsx @@ -20,16 +20,17 @@ import { api } from "@/utils/api"; interface Props { appName: string; + serverId?: string; } -export const ShowNomadAllocations = ({ appName }: Props) => { +export const ShowNomadAllocations = ({ appName, serverId }: Props) => { const { data: allocs, isLoading, isError, refetch, } = api.nomad.getJobAllocations.useQuery( - { jobId: appName }, + { jobId: appName, serverId }, { enabled: !!appName, refetchInterval: 10000 }, ); @@ -69,14 +70,20 @@ export const ShowNomadAllocations = ({ appName }: Props) => {

)} {allocs?.map((alloc: any) => ( - + ))} ); }; -const AllocationRow = ({ alloc }: { alloc: any }) => { +const AllocationRow = ({ + alloc, + serverId, +}: { + alloc: any; + serverId?: string; +}) => { const [open, setOpen] = useState(false); const [logType, setLogType] = useState<"stdout" | "stderr">("stdout"); @@ -122,6 +129,7 @@ const AllocationRow = ({ alloc }: { alloc: any }) => { allocId={alloc.ID} taskName={taskName} logType={logType} + serverId={serverId} /> )} @@ -135,16 +143,21 @@ const AllocLogViewer = ({ allocId, taskName, logType, + serverId, }: { allocId: string; taskName: string; logType: "stdout" | "stderr"; + serverId?: string; }) => { - const { data: logs, isLoading, refetch } = - api.nomad.getAllocationLogs.useQuery( - { allocId, taskName, logType }, - { refetchInterval: 5000 }, - ); + const { + data: logs, + isLoading, + refetch, + } = api.nomad.getAllocationLogs.useQuery( + { allocId, taskName, logType, serverId }, + { refetchInterval: 5000 }, + ); return (
diff --git a/apps/dokploy/components/dashboard/nomad/jobs/show-nomad-jobs.tsx b/apps/dokploy/components/dashboard/nomad/jobs/show-nomad-jobs.tsx index b3822b8e2..243ecc024 100644 --- a/apps/dokploy/components/dashboard/nomad/jobs/show-nomad-jobs.tsx +++ b/apps/dokploy/components/dashboard/nomad/jobs/show-nomad-jobs.tsx @@ -1,4 +1,11 @@ -import { AlertTriangle, Loader2, Play, RefreshCw, Square, Trash2 } from "lucide-react"; +import { + AlertTriangle, + Loader2, + Play, + RefreshCw, + Square, + Trash2, +} from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -26,13 +33,13 @@ import { } from "@/components/ui/table"; import { api } from "@/utils/api"; -export const ShowNomadJobs = () => { +export const ShowNomadJobs = ({ serverId }: { serverId?: string }) => { const { data: jobs, isLoading, isError, refetch, - } = api.nomad.getJobs.useQuery(); + } = api.nomad.getJobs.useQuery({ serverId }); const stopJob = api.nomad.stopJob.useMutation({ onSuccess: () => { @@ -95,7 +102,9 @@ export const ShowNomadJobs = () => { Object.entries(job.JobSummary.Summary).map( ([group, summary]: [string, any]) => (
- {group}: {summary.Running}/{summary.Running + summary.Starting + summary.Queued} running + {group}: {summary.Running}/ + {summary.Running + summary.Starting + summary.Queued}{" "} + running
), )} @@ -110,15 +119,24 @@ export const ShowNomadJobs = () => { - Stop job "{job.Name}"? + + Stop job "{job.Name}"? + - This will stop all allocations for this job. You can restart it later. + This will stop all allocations for this job. You can + restart it later. Cancel stopJob.mutate({ jobId: job.ID, purge: false })} + onClick={() => + stopJob.mutate({ + jobId: job.ID, + purge: false, + serverId, + }) + } > Stop @@ -134,16 +152,25 @@ export const ShowNomadJobs = () => { - Purge job "{job.Name}"? + + Purge job "{job.Name}"? + - This will permanently remove the job and all its history. This cannot be undone. + This will permanently remove the job and all its + history. This cannot be undone. Cancel stopJob.mutate({ jobId: job.ID, purge: true })} + onClick={() => + stopJob.mutate({ + jobId: job.ID, + purge: true, + serverId, + }) + } > Purge @@ -156,7 +183,10 @@ export const ShowNomadJobs = () => { ))} {(!jobs || jobs.length === 0) && ( - + No jobs running diff --git a/apps/dokploy/components/dashboard/nomad/logs/show-nomad-logs.tsx b/apps/dokploy/components/dashboard/nomad/logs/show-nomad-logs.tsx index b33ebd516..c667ed3b4 100644 --- a/apps/dokploy/components/dashboard/nomad/logs/show-nomad-logs.tsx +++ b/apps/dokploy/components/dashboard/nomad/logs/show-nomad-logs.tsx @@ -12,7 +12,7 @@ import { } from "@/components/ui/select"; import { api } from "@/utils/api"; -export const ShowNomadLogs = () => { +export const ShowNomadLogs = ({ serverId }: { serverId?: string }) => { const [selectedAlloc, setSelectedAlloc] = useState(null); const [selectedTask, setSelectedTask] = useState(""); const [logType, setLogType] = useState<"stdout" | "stderr">("stdout"); @@ -22,9 +22,10 @@ export const ShowNomadLogs = () => { isLoading, isError, refetch, - } = api.nomad.getAllocations.useQuery(); + } = api.nomad.getAllocations.useQuery({ serverId }); - const runningAllocs = allocs?.filter((a: any) => a.ClientStatus === "running") || []; + const runningAllocs = + allocs?.filter((a: any) => a.ClientStatus === "running") || []; const currentAlloc = runningAllocs.find((a: any) => a.ID === selectedAlloc); @@ -92,6 +93,7 @@ export const ShowNomadLogs = () => { allocId={selectedAlloc} taskName={selectedTask} logType={logType} + serverId={serverId} /> )} @@ -105,13 +107,19 @@ const LogViewer = ({ allocId, taskName, logType, + serverId, }: { allocId: string; taskName: string; logType: "stdout" | "stderr"; + serverId?: string; }) => { - const { data: logs, isLoading, refetch } = api.nomad.getAllocationLogs.useQuery( - { allocId, taskName, logType }, + const { + data: logs, + isLoading, + refetch, + } = api.nomad.getAllocationLogs.useQuery( + { allocId, taskName, logType, serverId }, { refetchInterval: 5000 }, ); diff --git a/apps/dokploy/components/dashboard/nomad/nodes/show-nomad-nodes.tsx b/apps/dokploy/components/dashboard/nomad/nodes/show-nomad-nodes.tsx index fad0a3010..9d50da9f8 100644 --- a/apps/dokploy/components/dashboard/nomad/nodes/show-nomad-nodes.tsx +++ b/apps/dokploy/components/dashboard/nomad/nodes/show-nomad-nodes.tsx @@ -14,17 +14,19 @@ import { } from "@/components/ui/table"; import { api } from "@/utils/api"; -export const ShowNomadNodes = () => { +export const ShowNomadNodes = ({ serverId }: { serverId?: string }) => { const { data: nodes, isLoading, isError, refetch, - } = api.nomad.getNodes.useQuery(); + } = api.nomad.getNodes.useQuery({ serverId }); - const { data: resources } = api.nomad.getClusterResources.useQuery(); + const { data: resources } = api.nomad.getClusterResources.useQuery({ + serverId, + }); - const { data: allocs } = api.nomad.getAllocations.useQuery(); + const { data: allocs } = api.nomad.getAllocations.useQuery({ serverId }); if (isLoading) { return ( @@ -67,18 +69,26 @@ export const ShowNomadNodes = () => { {nodes?.map((node: any) => { - const nodeAllocs = allocs?.filter( - (a: any) => a.NodeID === node.ID && a.ClientStatus === "running", - ) || []; + const nodeAllocs = + allocs?.filter( + (a: any) => + a.NodeID === node.ID && a.ClientStatus === "running", + ) || []; const cpuTotal = resources?.cpu.total || 1; const memTotal = resources?.memory.total || 1; - const cpuPercent = cpuTotal > 0 - ? Math.round((resources?.cpu.allocated || 0) / cpuTotal * 100) - : 0; - const memPercent = memTotal > 0 - ? Math.round((resources?.memory.allocated || 0) / memTotal * 100) - : 0; + const cpuPercent = + cpuTotal > 0 + ? Math.round( + ((resources?.cpu.allocated || 0) / cpuTotal) * 100, + ) + : 0; + const memPercent = + memTotal > 0 + ? Math.round( + ((resources?.memory.allocated || 0) / memTotal) * 100, + ) + : 0; return ( @@ -89,7 +99,11 @@ export const ShowNomadNodes = () => {
- + {node.Status} @@ -98,13 +112,17 @@ export const ShowNomadNodes = () => {
- {cpuPercent}% + + {cpuPercent}% +
- {memPercent}% + + {memPercent}% +
@@ -112,7 +130,10 @@ export const ShowNomadNodes = () => { })} {(!nodes || nodes.length === 0) && ( - + No nodes found diff --git a/apps/dokploy/components/dashboard/nomad/overview.tsx b/apps/dokploy/components/dashboard/nomad/overview.tsx index 66807f35f..7d42cf7a5 100644 --- a/apps/dokploy/components/dashboard/nomad/overview.tsx +++ b/apps/dokploy/components/dashboard/nomad/overview.tsx @@ -1,11 +1,18 @@ -import { Cpu, HardDrive, Loader2, MemoryStick, Server, Container } from "lucide-react"; +import { + Container, + Cpu, + HardDrive, + Loader2, + MemoryStick, + Server, +} from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { api } from "@/utils/api"; -export const NomadOverview = () => { +export const NomadOverview = ({ serverId }: { serverId?: string }) => { const { data, isLoading } = api.nomad.getClusterResources.useQuery( - undefined, + { serverId }, { refetchInterval: 10000 }, ); @@ -17,12 +24,14 @@ export const NomadOverview = () => { ); } - const cpuPercent = data.cpu.total > 0 - ? Math.round((data.cpu.allocated / data.cpu.total) * 100) - : 0; - const memPercent = data.memory.total > 0 - ? Math.round((data.memory.allocated / data.memory.total) * 100) - : 0; + const cpuPercent = + data.cpu.total > 0 + ? Math.round((data.cpu.allocated / data.cpu.total) * 100) + : 0; + const memPercent = + data.memory.total > 0 + ? Math.round((data.memory.allocated / data.memory.total) * 100) + : 0; return (
@@ -49,7 +58,8 @@ export const NomadOverview = () => {
{memPercent}%

- {formatMB(data.memory.allocated)} / {formatMB(data.memory.total)} allocated + {formatMB(data.memory.allocated)} / {formatMB(data.memory.total)}{" "} + allocated

diff --git a/apps/dokploy/components/dashboard/settings/servers/nomad-settings-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/nomad-settings-modal.tsx new file mode 100644 index 000000000..96af646b4 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/nomad-settings-modal.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { NomadSettings } from "./nomad-settings"; + +interface Props { + serverId: string; +} + +export const NomadSettingsModal = ({ serverId }: Props) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + { + e.preventDefault(); + setIsOpen(true); + }} + > + Nomad + + + + Nomad + + Configure this server's Nomad connection, or bootstrap the Nomad + stack (Docker, Consul, Nomad, CNI) on it. + + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/nomad-settings.tsx b/apps/dokploy/components/dashboard/settings/servers/nomad-settings.tsx index d7e1055c4..391d5c856 100644 --- a/apps/dokploy/components/dashboard/settings/servers/nomad-settings.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/nomad-settings.tsx @@ -1,4 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2, Terminal } from "lucide-react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -43,6 +45,34 @@ export const NomadSettings = ({ serverId }: Props) => { const { mutateAsync, isPending } = api.server.update.useMutation(); + const [isBootstrapping, setIsBootstrapping] = useState(false); + const [bootstrapLogs, setBootstrapLogs] = useState(""); + + api.nomad.bootstrapServer.useSubscription( + { serverId }, + { + enabled: isBootstrapping, + onData(log) { + if (log === "BOOTSTRAP_DONE") { + setIsBootstrapping(false); + toast.success("Nomad bootstrapped on this server"); + refetch(); + return; + } + setBootstrapLogs((prev) => prev + log); + }, + onError(error) { + setIsBootstrapping(false); + toast.error(error.message || "Bootstrap failed"); + }, + }, + ); + + const startBootstrap = () => { + setBootstrapLogs(""); + setIsBootstrapping(true); + }; + const form = useForm({ resolver: zodResolver(nomadSchema), values: { @@ -70,11 +100,27 @@ export const NomadSettings = ({ serverId }: Props) => { return ( - - Nomad Configuration - - Configure Nomad cluster connection for deploying services. - + +
+ Nomad Configuration + + Configure Nomad cluster connection for deploying services. + +
+
@@ -156,6 +202,12 @@ export const NomadSettings = ({ serverId }: Props) => {
+ + {(isBootstrapping || bootstrapLogs) && ( +
+						{bootstrapLogs || "Starting bootstrap…"}
+					
+ )}
); diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 832d04759..3df7105e9 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -41,6 +41,7 @@ import { api } from "@/utils/api"; import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal"; import { TerminalModal } from "../web-server/terminal-modal"; import { ShowServerActions } from "./actions/show-server-actions"; +import { NomadSettingsModal } from "./nomad-settings-modal"; import { HandleServers } from "./handle-servers"; import { SetupServer } from "./setup-server"; import { ShowDockerContainersModal } from "./show-docker-containers-modal"; @@ -181,6 +182,9 @@ export const ShowServers = () => { + )} diff --git a/apps/dokploy/pages/dashboard/nomad.tsx b/apps/dokploy/pages/dashboard/nomad.tsx index 4751b099e..bede79894 100644 --- a/apps/dokploy/pages/dashboard/nomad.tsx +++ b/apps/dokploy/pages/dashboard/nomad.tsx @@ -1,18 +1,52 @@ import { IS_CLOUD } from "@nomploy/server/constants"; import { validateRequest } from "@nomploy/server/lib/auth"; import type { GetServerSidePropsContext } from "next"; -import type { ReactElement } from "react"; -import { DashboardLayout } from "@/components/layouts/dashboard-layout"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { NomadOverview } from "@/components/dashboard/nomad/overview"; +import { type ReactElement, useState } from "react"; import { ShowNomadJobs } from "@/components/dashboard/nomad/jobs/show-nomad-jobs"; -import { ShowNomadNodes } from "@/components/dashboard/nomad/nodes/show-nomad-nodes"; import { ShowNomadLogs } from "@/components/dashboard/nomad/logs/show-nomad-logs"; +import { ShowNomadNodes } from "@/components/dashboard/nomad/nodes/show-nomad-nodes"; +import { NomadOverview } from "@/components/dashboard/nomad/overview"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { api } from "@/utils/api"; + +// Sentinel for the control plane's own local Nomad (no serverId sent). +const LOCAL = "local"; const NomadDashboard = () => { + const [selected, setSelected] = useState(LOCAL); + const { data: servers } = api.server.all.useQuery(); + + const serverId = selected === LOCAL ? undefined : selected; + return (
- +
+

Nomad

+ +
+ + Jobs @@ -20,13 +54,13 @@ const NomadDashboard = () => { Logs - + - + - +
@@ -39,9 +73,7 @@ NomadDashboard.getLayout = (page: ReactElement) => { return {page}; }; -export async function getServerSideProps( - ctx: GetServerSidePropsContext, -) { +export async function getServerSideProps(ctx: GetServerSidePropsContext) { if (IS_CLOUD) { return { redirect: { permanent: false, destination: "/dashboard/home" }, diff --git a/apps/dokploy/server/api/routers/nomad.ts b/apps/dokploy/server/api/routers/nomad.ts index 211ea13a0..f93d44a7a 100644 --- a/apps/dokploy/server/api/routers/nomad.ts +++ b/apps/dokploy/server/api/routers/nomad.ts @@ -1,89 +1,182 @@ +import { findServerById, updateServerById } from "@nomploy/server"; +import { getNomadBootstrapCommand } from "@nomploy/server/setup/nomad-bootstrap"; +import { execAsyncRemote } from "@nomploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; import { z } from "zod"; import { createTRPCRouter, withPermission } from "../trpc"; -const NOMAD_ADDRESS = process.env.NOMAD_ADDRESS || "http://127.0.0.1:4646"; -const NOMAD_TOKEN = process.env.NOMAD_TOKEN || ""; +// Control-plane-local Nomad (used when no serverId is given). +const DEFAULT_ADDRESS = process.env.NOMAD_ADDRESS || "http://127.0.0.1:4646"; +const DEFAULT_TOKEN = process.env.NOMAD_TOKEN || ""; -const nomadFetch = async (path: string) => { - const headers: Record = { - "Content-Type": "application/json", - }; - if (NOMAD_TOKEN) { - headers["X-Nomad-Token"] = NOMAD_TOKEN; +interface NomadConfig { + address: string; + token: string; + namespace: string; +} + +/** + * Resolve which Nomad cluster a request targets. + * - With a serverId: use that server's stored connection (nomadAddress / nomadToken + * / nomadNamespace), after verifying the server belongs to the caller's org. + * - Without: fall back to the control plane's own local Nomad. + * + * NOTE: transport is plain HTTP here. The seam for a future SSH-tunnel is + * `nomadClient()` below — swap how the request is made without touching callers. + */ +const resolveNomad = async ( + ctx: { session?: { activeOrganizationId?: string } | null }, + serverId?: string, +): Promise => { + if (!serverId) { + return { + address: DEFAULT_ADDRESS, + token: DEFAULT_TOKEN, + namespace: "default", + }; } - const res = await fetch(`${NOMAD_ADDRESS}/v1${path}`, { headers }); - if (!res.ok) { + const server = await findServerById(serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + if (!server.nomadAddress) { throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Nomad API error: ${res.status} ${res.statusText}`, + code: "BAD_REQUEST", + message: + "This server has no Nomad address configured. Set it in the server's Nomad settings.", }); } - return res.json(); + + return { + address: server.nomadAddress, + token: server.nomadToken ?? "", + namespace: server.nomadNamespace ?? "default", + }; +}; + +const nomadClient = (cfg: NomadConfig) => { + const headers: Record = { + "Content-Type": "application/json", + }; + if (cfg.token) headers["X-Nomad-Token"] = cfg.token; + + const url = (path: string) => { + const base = `${cfg.address.replace(/\/$/, "")}/v1${path}`; + return base; + }; + + return { + namespace: cfg.namespace, + async request(path: string, init?: RequestInit) { + return fetch(url(path), { + ...init, + headers: { ...headers, ...init?.headers }, + }); + }, + async get(path: string) { + const res = await this.request(path); + if (!res.ok) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Nomad API error: ${res.status} ${res.statusText}`, + }); + } + return res.json(); + }, + }; +}; + +// Append a namespace query param to a path (for namespaced endpoints). +const withNs = (path: string, namespace: string) => { + if (!namespace || namespace === "*") return path; + const sep = path.includes("?") ? "&" : "?"; + return `${path}${sep}namespace=${encodeURIComponent(namespace)}`; }; +const serverInput = z.object({ serverId: z.string().optional() }); + export const nomadRouter = createTRPCRouter({ getJobs: withPermission("server", "read") - .query(async () => { - return await nomadFetch("/jobs"); + .input(serverInput) + .query(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + return nomadClient(cfg).get(withNs("/jobs", cfg.namespace)); }), getJob: withPermission("server", "read") - .input(z.object({ jobId: z.string() })) - .query(async ({ input }) => { - return await nomadFetch(`/job/${input.jobId}`); + .input(serverInput.extend({ jobId: z.string() })) + .query(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + return nomadClient(cfg).get(withNs(`/job/${input.jobId}`, cfg.namespace)); }), getJobAllocations: withPermission("server", "read") - .input(z.object({ jobId: z.string() })) - .query(async ({ input }) => { - return await nomadFetch(`/job/${input.jobId}/allocations`); + .input(serverInput.extend({ jobId: z.string() })) + .query(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + return nomadClient(cfg).get( + withNs(`/job/${input.jobId}/allocations`, cfg.namespace), + ); }), getJobScale: withPermission("server", "read") - .input(z.object({ jobId: z.string() })) - .query(async ({ input }) => { - return await nomadFetch(`/job/${input.jobId}/scale`); + .input(serverInput.extend({ jobId: z.string() })) + .query(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + return nomadClient(cfg).get( + withNs(`/job/${input.jobId}/scale`, cfg.namespace), + ); }), getAllocations: withPermission("server", "read") - .query(async () => { - return await nomadFetch("/allocations"); + .input(serverInput) + .query(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + return nomadClient(cfg).get(withNs("/allocations", cfg.namespace)); }), getAllocation: withPermission("server", "read") - .input(z.object({ allocId: z.string() })) - .query(async ({ input }) => { - return await nomadFetch(`/allocation/${input.allocId}`); + .input(serverInput.extend({ allocId: z.string() })) + .query(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + return nomadClient(cfg).get(`/allocation/${input.allocId}`); }), getAllocationLogs: withPermission("server", "read") - .input(z.object({ - allocId: z.string(), - taskName: z.string(), - logType: z.enum(["stdout", "stderr"]).default("stdout"), - })) - .query(async ({ input }) => { - const res = await fetch( - `${NOMAD_ADDRESS}/v1/client/fs/logs/${input.allocId}?task=${input.taskName}&type=${input.logType}&plain=true`, - { - headers: NOMAD_TOKEN ? { "X-Nomad-Token": NOMAD_TOKEN } : {}, - }, + .input( + serverInput.extend({ + allocId: z.string(), + taskName: z.string(), + logType: z.enum(["stdout", "stderr"]).default("stdout"), + }), + ) + .query(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + const res = await nomadClient(cfg).request( + `/client/fs/logs/${input.allocId}?task=${input.taskName}&type=${input.logType}&plain=true`, ); if (!res.ok) return ""; - return await res.text(); + return res.text(); }), getNodes: withPermission("server", "read") - .query(async () => { - return await nomadFetch("/nodes"); + .input(serverInput) + .query(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + return nomadClient(cfg).get("/nodes"); }), getClusterResources: withPermission("server", "read") - .query(async () => { - const nodes: any[] = await nomadFetch("/nodes"); - const allocs: any[] = await nomadFetch("/allocations?resources=true"); + .input(serverInput) + .query(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + const client = nomadClient(cfg); + const nodes: any[] = await client.get("/nodes"); + const allocs: any[] = await client.get( + withNs("/allocations?resources=true", cfg.namespace), + ); let totalCpu = 0; let totalMemory = 0; @@ -91,13 +184,12 @@ export const nomadRouter = createTRPCRouter({ let allocatedCpu = 0; let allocatedMemory = 0; let runningAllocs = 0; - let totalAllocs = allocs.length; + const totalAllocs = allocs.length; - // Get detailed node info for resources for (const node of nodes) { if (node.Status !== "ready") continue; try { - const detail: any = await nomadFetch(`/node/${node.ID}`); + const detail: any = await client.get(`/node/${node.ID}`); const res = detail.NodeResources || {}; totalCpu += res.Cpu?.CpuShares || 0; totalMemory += res.Memory?.MemoryMB || 0; @@ -105,7 +197,6 @@ export const nomadRouter = createTRPCRouter({ } catch {} } - // Sum allocated resources from running allocations for (const alloc of allocs) { if (alloc.ClientStatus !== "running") continue; runningAllocs++; @@ -119,78 +210,110 @@ export const nomadRouter = createTRPCRouter({ return { nodes: nodes.length, nodesReady: nodes.filter((n: any) => n.Status === "ready").length, - cpu: { total: totalCpu, allocated: allocatedCpu, free: totalCpu - allocatedCpu }, - memory: { total: totalMemory, allocated: allocatedMemory, free: totalMemory - allocatedMemory }, + cpu: { + total: totalCpu, + allocated: allocatedCpu, + free: totalCpu - allocatedCpu, + }, + memory: { + total: totalMemory, + allocated: allocatedMemory, + free: totalMemory - allocatedMemory, + }, disk: { total: totalDisk }, allocations: { running: runningAllocs, total: totalAllocs }, }; }), getNode: withPermission("server", "read") - .input(z.object({ nodeId: z.string() })) - .query(async ({ input }) => { - return await nomadFetch(`/node/${input.nodeId}`); + .input(serverInput.extend({ nodeId: z.string() })) + .query(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + return nomadClient(cfg).get(`/node/${input.nodeId}`); }), scaleJob: withPermission("server", "create") - .input(z.object({ - jobId: z.string(), - group: z.string(), - count: z.number().min(0), - })) - .mutation(async ({ input }) => { - const headers: Record = { - "Content-Type": "application/json", - }; - if (NOMAD_TOKEN) { - headers["X-Nomad-Token"] = NOMAD_TOKEN; - } - - const res = await fetch( - `${NOMAD_ADDRESS}/v1/job/${input.jobId}/scale`, + .input( + serverInput.extend({ + jobId: z.string(), + group: z.string(), + count: z.number().min(0), + }), + ) + .mutation(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + const res = await nomadClient(cfg).request( + withNs(`/job/${input.jobId}/scale`, cfg.namespace), { method: "POST", - headers, body: JSON.stringify({ Count: input.count, - Target: { - Group: input.group, - }, + Target: { Group: input.group }, }), }, ); - if (!res.ok) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Scale failed: ${res.status}`, }); } - return await res.json(); + return res.json(); }), stopJob: withPermission("server", "create") - .input(z.object({ jobId: z.string(), purge: z.boolean().default(false) })) - .mutation(async ({ input }) => { - const headers: Record = {}; - if (NOMAD_TOKEN) { - headers["X-Nomad-Token"] = NOMAD_TOKEN; - } - - const res = await fetch( - `${NOMAD_ADDRESS}/v1/job/${input.jobId}?purge=${input.purge}`, - { - method: "DELETE", - headers, - }, + .input( + serverInput.extend({ + jobId: z.string(), + purge: z.boolean().default(false), + }), + ) + .mutation(async ({ input, ctx }) => { + const cfg = await resolveNomad(ctx, input.serverId); + const res = await nomadClient(cfg).request( + withNs(`/job/${input.jobId}?purge=${input.purge}`, cfg.namespace), + { method: "DELETE" }, ); - if (!res.ok) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Stop failed: ${res.status}`, }); } - return await res.json(); + return res.json(); + }), + + // Install Docker + Consul + Nomad + CNI on a server over SSH, streaming logs. + bootstrapServer: withPermission("server", "create") + .input(z.object({ serverId: z.string() })) + .subscription(async ({ input, ctx }) => { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const command = getNomadBootstrapCommand({ + datacenter: server.nomadNamespace || "dc1", + }); + + return observable((emit) => { + execAsyncRemote(input.serverId, command, (log) => emit.next(log)) + .then(async () => { + // Point the control plane at this server's Nomad if not set yet. + if (!server.nomadAddress) { + const address = `http://${server.ipAddress}:4646`; + await updateServerById(input.serverId, { nomadAddress: address }); + emit.next(`\nSaved Nomad address: ${address} ✅\n`); + } + emit.next("BOOTSTRAP_DONE"); + emit.complete(); + }) + .catch((err: unknown) => { + const message = + err instanceof Error ? err.message : "Bootstrap failed"; + emit.next(`\n❌ ${message}\n`); + emit.complete(); + }); + }); }), }); diff --git a/install.sh b/install.sh new file mode 100755 index 000000000..da79ec10a --- /dev/null +++ b/install.sh @@ -0,0 +1,195 @@ +#!/bin/sh +# nomploy installer — sets up the nomploy control plane on a fresh Linux server. +# +# curl -sSL https://raw.githubusercontent.com/Nomploy/nomploy/main/install.sh | sh +# +# Installs: Docker, Consul, Nomad, CNI plugins, then runs Postgres, Redis, +# Traefik and the nomploy app. Idempotent — safe to re-run. +# +# nomploy is a fork of Dokploy (Apache-2.0) that uses HashiCorp Nomad as the +# orchestrator. See https://github.com/Nomploy/nomploy. + +set -e + +NOMPLOY_IMAGE="${NOMPLOY_IMAGE:-nomploy/nomploy:latest}" +NOMPLOY_PORT="${NOMPLOY_PORT:-3000}" +CNI_VERSION="${CNI_VERSION:-v1.5.1}" + +# ── privilege + platform detection ───────────────────────────────────────── +if [ "$(id -u)" -eq 0 ]; then + SUDO="" +else + if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then + SUDO="sudo" + else + echo "Error: run as root or with passwordless sudo." >&2 + exit 1 + fi +fi + +if [ ! -f /etc/os-release ]; then + echo "Error: unsupported OS (no /etc/os-release)." >&2 + exit 1 +fi +OS_TYPE="$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')" +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) CNI_ARCH=amd64 ;; + aarch64 | arm64) CNI_ARCH=arm64 ;; + *) echo "Error: unsupported architecture $ARCH." >&2; exit 1 ;; +esac + +echo "==> Installing nomploy on ${OS_TYPE} (${ARCH})" + +# ── Docker ────────────────────────────────────────────────────────────────── +if ! command -v docker >/dev/null 2>&1; then + echo "==> Installing Docker" + curl -fsSL https://get.docker.com | $SUDO sh + $SUDO systemctl enable --now docker +else + echo "Docker already installed." +fi + +# ── Consul + Nomad (HashiCorp repos) ───────────────────────────────────────── +install_hashicorp_debian() { + export DEBIAN_FRONTEND=noninteractive + $SUDO apt-get update -y + $SUDO apt-get install -y curl gnupg lsb-release + curl -fsSL https://apt.releases.hashicorp.com/gpg \ + | $SUDO gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \ + | $SUDO tee /etc/apt/sources.list.d/hashicorp.list >/dev/null + $SUDO apt-get update -y + $SUDO apt-get install -y nomad consul +} + +install_hashicorp_rhel() { + $SUDO yum install -y yum-utils + $SUDO yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo + $SUDO yum -y install nomad consul +} + +if ! command -v nomad >/dev/null 2>&1 || ! command -v consul >/dev/null 2>&1; then + echo "==> Installing Consul + Nomad" + case "$OS_TYPE" in + ubuntu | debian | raspbian | pop | linuxmint | zorin) install_hashicorp_debian ;; + centos | rhel | rocky | almalinux | fedora | amzn | ol) install_hashicorp_rhel ;; + *) echo "Error: unsupported OS $OS_TYPE for auto-install." >&2; exit 1 ;; + esac +else + echo "Consul + Nomad already installed." +fi + +# ── CNI plugins (Nomad bridge networking) ──────────────────────────────────── +if [ ! -f /opt/cni/bin/bridge ]; then + echo "==> Installing CNI plugins" + $SUDO mkdir -p /opt/cni/bin + curl -fsSL "https://github.com/containernetworking/plugins/releases/download/${CNI_VERSION}/cni-plugins-linux-${CNI_ARCH}-${CNI_VERSION}.tgz" \ + | $SUDO tar -C /opt/cni/bin -xz +fi +echo 1 | $SUDO tee /proc/sys/net/bridge/bridge-nf-call-iptables >/dev/null 2>&1 || true + +# ── Consul + Nomad config (single node: server + client) ───────────────────── +$SUDO mkdir -p /etc/consul.d /opt/consul /etc/nomad.d /opt/nomad + +$SUDO tee /etc/consul.d/consul.hcl >/dev/null <<'CONSULHCL' +data_dir = "/opt/consul" +bind_addr = "{{ GetPrivateIP }}" +client_addr = "0.0.0.0" +datacenter = "dc1" +server = true +bootstrap_expect = 1 +ui_config { enabled = true } +CONSULHCL + +$SUDO tee /etc/nomad.d/nomad.hcl >/dev/null <<'NOMADHCL' +data_dir = "/opt/nomad" +bind_addr = "0.0.0.0" +datacenter = "dc1" + +server { + enabled = true + bootstrap_expect = 1 +} + +client { + enabled = true +} + +consul { + address = "127.0.0.1:8500" +} + +plugin "docker" { + config { + allow_privileged = true + auth { + config = "/root/.docker/config.json" + } + } +} +NOMADHCL + +echo "==> Starting Consul + Nomad" +$SUDO systemctl enable consul nomad +$SUDO systemctl restart consul +sleep 3 +$SUDO systemctl restart nomad + +echo "==> Waiting for Nomad API" +i=0 +while [ "$i" -lt 30 ]; do + if curl -fsS http://127.0.0.1:4646/v1/agent/health >/dev/null 2>&1; then + echo "Nomad is up." + break + fi + i=$((i + 1)) + sleep 2 +done + +# ── Datastores ──────────────────────────────────────────────────────────────── +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-amukds4wi9001583845717ad2}" + +run_container() { + name="$1"; shift + if [ "$($SUDO docker ps -q -f name="^${name}$" -f status=running)" ]; then + echo "${name} already running." + return + fi + $SUDO docker rm -f "$name" >/dev/null 2>&1 || true + $SUDO docker run -d --name "$name" --restart unless-stopped "$@" +} + +echo "==> Starting Postgres + Redis" +run_container nomploy-postgres \ + -e POSTGRES_USER=nomploy -e POSTGRES_DB=nomploy \ + -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ + -v nomploy-postgres:/var/lib/postgresql/data \ + -p 127.0.0.1:5432:5432 postgres:16 +run_container nomploy-redis \ + -v nomploy-redis:/data -p 127.0.0.1:6379:6379 redis:7 + +# ── nomploy app ─────────────────────────────────────────────────────────────── +echo "==> Starting nomploy ($NOMPLOY_IMAGE)" +$SUDO docker pull "$NOMPLOY_IMAGE" +$SUDO docker rm -f nomploy >/dev/null 2>&1 || true +$SUDO docker run -d --name nomploy --restart unless-stopped \ + --network host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /etc/nomploy:/etc/nomploy \ + -e NODE_ENV=production \ + -e PORT="$NOMPLOY_PORT" \ + -e DATABASE_URL="postgresql://nomploy:${POSTGRES_PASSWORD}@127.0.0.1:5432/nomploy" \ + -e REDIS_URL="redis://127.0.0.1:6379" \ + -e NOMAD_ADDRESS="http://127.0.0.1:4646" \ + -e CONSUL_ADDRESS="http://127.0.0.1:8500" \ + "$NOMPLOY_IMAGE" + +IP="$(hostname -I 2>/dev/null | awk '{print $1}')" +echo "" +echo "==============================================" +echo " nomploy is starting." +echo " Open: http://${IP:-}:${NOMPLOY_PORT}" +echo " Nomad: http://${IP:-}:4646" +echo " Consul: http://${IP:-}:8500" +echo "==============================================" diff --git a/packages/server/src/setup/nomad-bootstrap.ts b/packages/server/src/setup/nomad-bootstrap.ts new file mode 100644 index 000000000..3f064febf --- /dev/null +++ b/packages/server/src/setup/nomad-bootstrap.ts @@ -0,0 +1,190 @@ +/** + * nomploy — Nomad bootstrap. + * + * Generates a shell script (run over SSH on a target server, the same way + * Dokploy's `serverSetup` runs `installRequirements`) that installs and starts a + * single-node HashiCorp stack: + * - Docker (container runtime) + * - Consul (service discovery — Traefik reads services from its catalog) + * - Nomad (orchestrator, server + client on one node) + * - CNI plugins (required for Nomad bridge networking) + * + * After this runs, `setupNomad()` (nomad-setup.ts) can bring up Traefik, the + * databases, and the autoscaler, because Nomad/Consul/Docker are now present. + * + * Multi-node: bootstrap the first node as the server, then bootstrap additional + * nodes as clients with `serverMode:false` and `retryJoin` pointing at the + * server's private IP. + */ + +export interface NomadBootstrapOptions { + /** Datacenter name Nomad/Consul register under. */ + datacenter?: string; + /** Address to advertise/bind on (defaults to auto-detected private IP). */ + bindAddr?: string; + /** Run as a Nomad+Consul server (true) or client-only node (false). */ + serverMode?: boolean; + /** Server addresses a client node should join (private IPs). */ + retryJoin?: string[]; + /** Nomad/Consul version to pin, or "latest" (default). */ + version?: string; +} + +export const getNomadBootstrapCommand = ( + opts: NomadBootstrapOptions = {}, +): string => { + const datacenter = opts.datacenter || "dc1"; + const serverMode = opts.serverMode !== false; + const retryJoin = opts.retryJoin ?? []; + const bindExpr = opts.bindAddr + ? `"${opts.bindAddr}"` + : // GO template resolved by Consul/Nomad to the first private interface. + `"{{ GetPrivateIP }}"`; + + // Consul retry_join list (client nodes point at the server). + const consulRetryJoin = + retryJoin.length > 0 + ? `retry_join = [${retryJoin.map((h) => `"${h}"`).join(", ")}]` + : ""; + + return ` +set -e +CURRENT_USER=$USER +if [ "$EUID" -eq 0 ]; then + SUDO="" +else + if sudo -n true 2>/dev/null; then + SUDO="sudo" + else + echo "Error: needs root or passwordless sudo. ❌" + echo " echo '$CURRENT_USER ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/$CURRENT_USER" + exit 1 + fi +fi + +OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') +ARCH=$(uname -m) +case "$ARCH" in + x86_64) CNI_ARCH=amd64 ;; + aarch64|arm64) CNI_ARCH=arm64 ;; + *) echo "Unsupported arch: $ARCH ❌"; exit 1 ;; +esac + +echo "==> Installing Nomad stack on $OS_TYPE ($ARCH)" + +# ── Docker ──────────────────────────────────────────────────────────────── +if ! command -v docker >/dev/null 2>&1; then + echo "==> Installing Docker" + curl -fsSL https://get.docker.com | $SUDO sh + $SUDO systemctl enable --now docker +else + echo "Docker already installed ✅" +fi + +# ── HashiCorp repo + Consul/Nomad ───────────────────────────────────────── +install_hashicorp_debian() { + export DEBIAN_FRONTEND=noninteractive + $SUDO apt-get update -y + $SUDO apt-get install -y curl gnupg lsb-release + curl -fsSL https://apt.releases.hashicorp.com/gpg | $SUDO gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | $SUDO tee /etc/apt/sources.list.d/hashicorp.list + $SUDO apt-get update -y + $SUDO apt-get install -y nomad consul +} + +install_hashicorp_rhel() { + $SUDO yum install -y yum-utils + $SUDO yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo + $SUDO yum -y install nomad consul +} + +if ! command -v nomad >/dev/null 2>&1 || ! command -v consul >/dev/null 2>&1; then + case "$OS_TYPE" in + ubuntu|debian|raspbian|pop|linuxmint|zorin) install_hashicorp_debian ;; + centos|rhel|rocky|almalinux|fedora|amzn|ol) install_hashicorp_rhel ;; + *) echo "Unsupported OS for auto-install: $OS_TYPE. Install nomad+consul manually. ❌"; exit 1 ;; + esac +else + echo "Nomad + Consul already installed ✅" +fi + +# ── CNI plugins (needed for Nomad bridge networking) ────────────────────── +if [ ! -f /opt/cni/bin/bridge ]; then + echo "==> Installing CNI plugins" + CNI_VERSION=v1.5.1 + $SUDO mkdir -p /opt/cni/bin + curl -fsSL "https://github.com/containernetworking/plugins/releases/download/\${CNI_VERSION}/cni-plugins-linux-\${CNI_ARCH}-\${CNI_VERSION}.tgz" \\ + | $SUDO tar -C /opt/cni/bin -xz +else + echo "CNI plugins already present ✅" +fi +# Let bridged traffic traverse iptables (required by Nomad bridge mode). +echo 1 | $SUDO tee /proc/sys/net/bridge/bridge-nf-call-iptables >/dev/null 2>&1 || true + +# ── Consul config ───────────────────────────────────────────────────────── +$SUDO mkdir -p /etc/consul.d /opt/consul +$SUDO tee /etc/consul.d/consul.hcl >/dev/null <<'CONSULHCL' +data_dir = "/opt/consul" +bind_addr = ${bindExpr} +client_addr = "0.0.0.0" +datacenter = "${datacenter}" +${serverMode ? "server = true\nbootstrap_expect = 1\nui_config { enabled = true }" : "server = false"} +${consulRetryJoin} +CONSULHCL + +# ── Nomad config ────────────────────────────────────────────────────────── +$SUDO mkdir -p /etc/nomad.d /opt/nomad +$SUDO tee /etc/nomad.d/nomad.hcl >/dev/null <<'NOMADHCL' +data_dir = "/opt/nomad" +bind_addr = "0.0.0.0" +datacenter = "${datacenter}" + +advertise { + http = ${bindExpr} + rpc = ${bindExpr} + serf = ${bindExpr} +} + +${serverMode ? "server {\n enabled = true\n bootstrap_expect = 1\n}" : "server { enabled = false }"} + +client { + enabled = true + ${retryJoin.length > 0 ? `servers = [${retryJoin.map((h) => `"${h}"`).join(", ")}]` : ""} +} + +consul { + address = "127.0.0.1:8500" +} + +plugin "docker" { + config { + allow_privileged = true + # Lets Nomad pull from private registries you've logged into. + auth { + config = "/root/.docker/config.json" + } + } +} +NOMADHCL + +# ── Enable + start services ─────────────────────────────────────────────── +echo "==> Starting Consul and Nomad" +$SUDO systemctl enable consul nomad +$SUDO systemctl restart consul +sleep 3 +$SUDO systemctl restart nomad + +# ── Wait for Nomad API ──────────────────────────────────────────────────── +echo "==> Waiting for Nomad API on :4646" +for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:4646/v1/agent/health >/dev/null 2>&1; then + echo "Nomad is up ✅" + break + fi + sleep 2 +done + +nomad node status || true +echo "==> Nomad bootstrap complete ✅" +`; +};