1338 lines
37 KiB
TypeScript
1338 lines
37 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
|
import { z } from "zod";
|
|
import { $Enums } from "@prisma/client";
|
|
import prisma from "../lib/prisma";
|
|
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
|
import { auditLog } from "../services/audit.service";
|
|
import { dispatchRuntimeNotification } from "../services/notification.service";
|
|
import * as ssh from "../services/ssh.service";
|
|
import { rebuildAppInstall } from "../services/app-install-rebuild.service";
|
|
import {
|
|
APP_TEMPLATES,
|
|
AppTemplate,
|
|
AppTemplatePreset,
|
|
} from "../config/app-templates";
|
|
import {
|
|
CATALOG_SOURCE_OPTIONS,
|
|
CatalogSourceMode,
|
|
getCatalogTemplates,
|
|
} from "../services/app-catalog";
|
|
|
|
function slugify(value: string): string {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 48);
|
|
}
|
|
|
|
function mergeTemplatePreset(
|
|
template: AppTemplate,
|
|
presetId?: string,
|
|
): AppTemplatePreset | undefined {
|
|
if (!presetId) return undefined;
|
|
return template.presets?.find((preset) => preset.id === presetId);
|
|
}
|
|
|
|
function firstDefined(...values: Array<string | undefined>): string {
|
|
for (const value of values) {
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
const TRUSTED_APP_SENSITIVE_PATHS: Record<string, string[]> = {
|
|
docker: ["/var/run/docker.sock"],
|
|
portainer: ["/var/run/docker.sock"],
|
|
traefik: ["/var/run/docker.sock"],
|
|
};
|
|
|
|
const ISOLATED_TEMPLATE_VOLUME_TARGETS: Record<string, string> = {
|
|
postgres: "/var/lib/postgresql/data",
|
|
mysql: "/var/lib/mysql",
|
|
mariadb: "/var/lib/mysql",
|
|
mongodb: "/data/db",
|
|
redis: "/data",
|
|
"supabase-postgres": "/var/lib/postgresql/data",
|
|
};
|
|
|
|
function resolveAppMountValidation(appId: string) {
|
|
const allowSensitivePaths = TRUSTED_APP_SENSITIVE_PATHS[appId];
|
|
return allowSensitivePaths ? { allowSensitivePaths } : undefined;
|
|
}
|
|
|
|
function resolveIsolatedTemplateVolumes(appId: string, containerName: string) {
|
|
const targetPath = ISOLATED_TEMPLATE_VOLUME_TARGETS[appId];
|
|
if (!targetPath) return "";
|
|
|
|
const hostSegment = slugify(containerName) || "database";
|
|
return `/srv/doktainer/databases/${hostSegment}:${targetPath}`;
|
|
}
|
|
|
|
type DockerInspectRuntime = {
|
|
Id?: string;
|
|
Name?: string;
|
|
Config?: {
|
|
Image?: string;
|
|
Env?: string[];
|
|
Cmd?: string[] | null;
|
|
};
|
|
State?: {
|
|
Status?: string;
|
|
};
|
|
HostConfig?: {
|
|
RestartPolicy?: { Name?: string | null };
|
|
PortBindings?: Record<
|
|
string,
|
|
Array<{ HostIp?: string; HostPort?: string }> | null
|
|
>;
|
|
NetworkMode?: string | null;
|
|
};
|
|
Mounts?: Array<{
|
|
Source?: string;
|
|
Destination?: string;
|
|
RW?: boolean;
|
|
}>;
|
|
};
|
|
|
|
type DockerPortBindings = Record<
|
|
string,
|
|
Array<{ HostIp?: string; HostPort?: string }> | null
|
|
>;
|
|
|
|
function formatEnvLines(env?: string[] | null): string {
|
|
return (env ?? []).filter(Boolean).join("\n");
|
|
}
|
|
|
|
function formatMountBindings(mounts?: DockerInspectRuntime["Mounts"]): string {
|
|
return (mounts ?? [])
|
|
.map((mount) => {
|
|
if (!mount.Source || !mount.Destination) return "";
|
|
return `${mount.Source}:${mount.Destination}${mount.RW === false ? ":ro" : ""}`;
|
|
})
|
|
.filter(Boolean)
|
|
.join(",");
|
|
}
|
|
|
|
function formatPortBindings(bindings?: DockerPortBindings): string {
|
|
if (!bindings) return "";
|
|
|
|
return Object.entries(bindings)
|
|
.flatMap(([containerPortSpec, hostBindings]) => {
|
|
if (!hostBindings || hostBindings.length === 0) return [];
|
|
const [containerPort, protocol = "tcp"] = containerPortSpec.split("/");
|
|
|
|
return hostBindings
|
|
.map((binding) => {
|
|
const hostPort = binding.HostPort?.trim();
|
|
if (!hostPort) return "";
|
|
return `${hostPort}:${containerPort}${protocol !== "tcp" ? `/${protocol}` : ""}`;
|
|
})
|
|
.filter(Boolean);
|
|
})
|
|
.join(",");
|
|
}
|
|
|
|
function formatCommandParts(cmd?: string[] | null): string {
|
|
return (cmd ?? []).filter(Boolean).join(" ").trim();
|
|
}
|
|
|
|
function parseCsvBindings(value: string): string[] {
|
|
return value
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseEnvironmentVariables(env: string): string[] {
|
|
return env
|
|
.split("\n")
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.includes("="));
|
|
}
|
|
|
|
function mapRuntimeStatus(value?: string | null): $Enums.AppInstallStatus {
|
|
const normalized = `${value ?? ""}`.trim().toLowerCase();
|
|
if (!normalized) return "UNKNOWN";
|
|
if (
|
|
normalized === "running" ||
|
|
normalized.startsWith("up ") ||
|
|
normalized.includes(" up ")
|
|
) {
|
|
return "RUNNING";
|
|
}
|
|
if (normalized === "paused" || normalized.startsWith("paused")) {
|
|
return "PAUSED";
|
|
}
|
|
if (
|
|
normalized === "restarting" ||
|
|
normalized.startsWith("restarting") ||
|
|
normalized === "created" ||
|
|
normalized.startsWith("created")
|
|
) {
|
|
return "STARTING";
|
|
}
|
|
if (
|
|
normalized === "exited" ||
|
|
normalized.startsWith("exited") ||
|
|
normalized === "dead" ||
|
|
normalized.startsWith("dead")
|
|
) {
|
|
return "STOPPED";
|
|
}
|
|
if (
|
|
normalized.startsWith("removing") ||
|
|
normalized.includes("removal in progress")
|
|
) {
|
|
return "STOPPING";
|
|
}
|
|
return "UNKNOWN";
|
|
}
|
|
|
|
function mapLifecycleActionToInstallStatus(
|
|
action: "start" | "stop" | "restart",
|
|
): "RUNNING" | "STOPPED" | "STARTING" {
|
|
if (action === "stop") return "STOPPED";
|
|
if (action === "restart") return "STARTING";
|
|
return "RUNNING";
|
|
}
|
|
|
|
async function upsertAppInstallerContainer(input: {
|
|
serverId: string;
|
|
environmentId?: string | null;
|
|
containerName: string;
|
|
image: string;
|
|
dockerId?: string | null;
|
|
ports: string;
|
|
env: string;
|
|
volumes: string;
|
|
restartPolicy: string;
|
|
}) {
|
|
const payload = {
|
|
dockerId: input.dockerId?.trim().slice(0, 12) || null,
|
|
name: input.containerName,
|
|
image: input.image,
|
|
status: "RUNNING" as const,
|
|
sourceType: "APP_INSTALLER" as const,
|
|
deployMode: "IMAGE" as const,
|
|
ports: JSON.parse(JSON.stringify(parseCsvBindings(input.ports))),
|
|
envVars: JSON.parse(JSON.stringify(parseEnvironmentVariables(input.env))),
|
|
volumes: JSON.parse(JSON.stringify(parseCsvBindings(input.volumes))),
|
|
restartPolicy: input.restartPolicy,
|
|
environmentId: input.environmentId ?? null,
|
|
};
|
|
|
|
const existingContainer = await prisma.container.findFirst({
|
|
where: {
|
|
serverId: input.serverId,
|
|
name: { equals: input.containerName, mode: "insensitive" },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
if (existingContainer) {
|
|
await prisma.container.update({
|
|
where: { id: existingContainer.id },
|
|
data: payload,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await prisma.container.create({
|
|
data: {
|
|
...payload,
|
|
serverId: input.serverId,
|
|
},
|
|
});
|
|
}
|
|
|
|
function classifyRuntimeError(
|
|
message: string,
|
|
): "CONTAINER_NOT_FOUND" | "INSPECT_FAILED" | "RUNTIME_UNAVAILABLE" {
|
|
const lower = message.toLowerCase();
|
|
if (
|
|
lower.includes("no such container") ||
|
|
lower.includes("no such object") ||
|
|
lower.includes("not found")
|
|
) {
|
|
return "CONTAINER_NOT_FOUND";
|
|
}
|
|
|
|
if (
|
|
lower.includes("cannot connect to the docker daemon") ||
|
|
lower.includes("permission denied") ||
|
|
lower.includes("docker.sock")
|
|
) {
|
|
return "RUNTIME_UNAVAILABLE";
|
|
}
|
|
|
|
return "INSPECT_FAILED";
|
|
}
|
|
|
|
function emptyRuntimeStats(): ssh.DockerContainerStats {
|
|
return {
|
|
cpuPercent: 0,
|
|
memoryPercent: 0,
|
|
pids: 0,
|
|
memory: {
|
|
raw: "",
|
|
usedBytes: null,
|
|
limitBytes: null,
|
|
},
|
|
network: {
|
|
raw: "",
|
|
totalBytes: null,
|
|
readBytes: null,
|
|
writeBytes: null,
|
|
},
|
|
io: {
|
|
raw: "",
|
|
totalBytes: null,
|
|
readBytes: null,
|
|
writeBytes: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
const RUNTIME_STATUS_CACHE_TTL_MS = 15 * 1000;
|
|
const RUNTIME_STATUS_FETCH_TIMEOUT_MS = 30 * 1000;
|
|
|
|
type RuntimeStatusCacheEntry = {
|
|
expiresAt: number;
|
|
statuses: Map<string, $Enums.AppInstallStatus>;
|
|
};
|
|
|
|
const runtimeStatusCache = new Map<string, RuntimeStatusCacheEntry>();
|
|
|
|
function resolveRuntimeStatusesWithTimeout(
|
|
server: Awaited<ReturnType<typeof prisma.server.findMany>>[number],
|
|
forceRefresh = false,
|
|
): Promise<Map<string, $Enums.AppInstallStatus>> {
|
|
const staleStatuses = runtimeStatusCache.get(server.id)?.statuses;
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
|
|
const complete = (statuses: Map<string, $Enums.AppInstallStatus>) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
|
|
settled = true;
|
|
resolve(statuses);
|
|
};
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
// If a server is offline or SSH is slow, keep the DB status instead of
|
|
// blocking the whole Installed Apps response past the timeout window.
|
|
complete(staleStatuses ?? new Map<string, $Enums.AppInstallStatus>());
|
|
}, RUNTIME_STATUS_FETCH_TIMEOUT_MS);
|
|
|
|
void getRuntimeStatusesForServer(server, forceRefresh)
|
|
.then((statuses) => {
|
|
clearTimeout(timeoutId);
|
|
complete(statuses);
|
|
})
|
|
.catch(() => {
|
|
clearTimeout(timeoutId);
|
|
complete(staleStatuses ?? new Map<string, $Enums.AppInstallStatus>());
|
|
});
|
|
});
|
|
}
|
|
|
|
async function getRuntimeStatusesForServer(
|
|
server: Awaited<ReturnType<typeof prisma.server.findMany>>[number],
|
|
forceRefresh = false,
|
|
): Promise<Map<string, $Enums.AppInstallStatus>> {
|
|
const now = Date.now();
|
|
const cached = runtimeStatusCache.get(server.id);
|
|
|
|
if (!forceRefresh && cached && cached.expiresAt > now) {
|
|
return cached.statuses;
|
|
}
|
|
|
|
try {
|
|
const containers = await ssh.listDockerContainers(server);
|
|
const statuses = new Map(
|
|
containers
|
|
.filter((container) => container.name)
|
|
.map((container) => [
|
|
container.name.trim().toLowerCase(),
|
|
mapRuntimeStatus(container.status),
|
|
]),
|
|
);
|
|
|
|
runtimeStatusCache.set(server.id, {
|
|
expiresAt: now + RUNTIME_STATUS_CACHE_TTL_MS,
|
|
statuses,
|
|
});
|
|
|
|
return statuses;
|
|
} catch {
|
|
const empty = new Map<string, $Enums.AppInstallStatus>();
|
|
runtimeStatusCache.set(server.id, {
|
|
expiresAt: now + RUNTIME_STATUS_CACHE_TTL_MS,
|
|
statuses: empty,
|
|
});
|
|
return empty;
|
|
}
|
|
}
|
|
|
|
async function overlayInstallRuntimeStatuses<
|
|
T extends {
|
|
id: string;
|
|
serverId: string;
|
|
containerName: string | null;
|
|
status: $Enums.AppInstallStatus;
|
|
error?: string | null;
|
|
},
|
|
>(installs: T[]): Promise<T[]> {
|
|
if (installs.length === 0) return installs;
|
|
|
|
const serverIds = [...new Set(installs.map((install) => install.serverId))];
|
|
const servers = await prisma.server.findMany({
|
|
where: { id: { in: serverIds } },
|
|
});
|
|
const serverById = new Map(servers.map((server) => [server.id, server]));
|
|
const runtimeStatusByServer = new Map<
|
|
string,
|
|
Map<string, $Enums.AppInstallStatus>
|
|
>();
|
|
|
|
await Promise.all(
|
|
serverIds.map(async (serverId) => {
|
|
const server = serverById.get(serverId);
|
|
if (!server) return;
|
|
|
|
runtimeStatusByServer.set(
|
|
serverId,
|
|
await resolveRuntimeStatusesWithTimeout(server),
|
|
);
|
|
}),
|
|
);
|
|
|
|
const updates: Array<{
|
|
id: string;
|
|
status: $Enums.AppInstallStatus;
|
|
error: string | null;
|
|
}> = [];
|
|
const persistableStatuses: ReadonlySet<$Enums.AppInstallStatus> = new Set([
|
|
"RUNNING",
|
|
"STARTING",
|
|
"STOPPING",
|
|
"STOPPED",
|
|
"PAUSED",
|
|
"UNKNOWN",
|
|
]);
|
|
const persistableFromDbStatuses: ReadonlySet<$Enums.AppInstallStatus> =
|
|
new Set([
|
|
"RUNNING",
|
|
"STARTING",
|
|
"STOPPING",
|
|
"STOPPED",
|
|
"PAUSED",
|
|
"UNKNOWN",
|
|
]);
|
|
const nextInstalls = installs.map((install) => {
|
|
// REMOVED is a logical deletion; never override it with runtime state.
|
|
// Otherwise previously-removed installs can get "resurrected" in the UI.
|
|
if (install.status === "REMOVED") return install;
|
|
if (!install.containerName) return install;
|
|
|
|
if (!persistableFromDbStatuses.has(install.status)) {
|
|
return install;
|
|
}
|
|
|
|
const runtimeStatus = runtimeStatusByServer
|
|
.get(install.serverId)
|
|
?.get(install.containerName.trim().toLowerCase());
|
|
|
|
if (!runtimeStatus) return install;
|
|
|
|
if (
|
|
persistableStatuses.has(runtimeStatus) &&
|
|
(runtimeStatus !== install.status || install.error)
|
|
) {
|
|
updates.push({ id: install.id, status: runtimeStatus, error: null });
|
|
}
|
|
|
|
return {
|
|
...install,
|
|
status: runtimeStatus,
|
|
error: null,
|
|
};
|
|
});
|
|
|
|
if (updates.length > 0) {
|
|
setImmediate(() => {
|
|
void Promise.allSettled(
|
|
updates.map((update) =>
|
|
prisma.appInstall.update({
|
|
where: { id: update.id },
|
|
data: { status: update.status, error: update.error },
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
return nextInstalls;
|
|
}
|
|
|
|
const templatePresetSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
desc: z.string(),
|
|
defaultPort: z.string().optional(),
|
|
defaultEnv: z.string().optional(),
|
|
defaultVolumes: z.string().optional(),
|
|
defaultCommand: z.string().optional(),
|
|
defaultNetwork: z.string().optional(),
|
|
restartPolicy: z.string().optional(),
|
|
});
|
|
|
|
const templateSnapshotSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
desc: z.string(),
|
|
category: z.string(),
|
|
icon: z.string().url().optional(),
|
|
image: z.string().min(1),
|
|
defaultPort: z.string(),
|
|
defaultEnv: z.string(),
|
|
defaultVolumes: z.string().optional(),
|
|
defaultCommand: z.string().optional(),
|
|
defaultNetwork: z.string().optional(),
|
|
restartPolicy: z.string(),
|
|
popular: z.boolean(),
|
|
tags: z.array(z.string()),
|
|
presets: z.array(templatePresetSchema).optional(),
|
|
});
|
|
|
|
const installSchema = z
|
|
.object({
|
|
mode: z.enum(["template", "custom"]).default("template"),
|
|
appId: z.string().optional(),
|
|
presetId: z.string().optional(),
|
|
templateSnapshot: templateSnapshotSchema.optional(),
|
|
serverId: z.string(),
|
|
environmentId: z.string().trim().max(64).optional().or(z.literal("")),
|
|
appName: z.string().min(1).max(120).optional(),
|
|
image: z.string().min(1).max(255).optional(),
|
|
tag: z.string().max(120).optional(),
|
|
containerName: z.string().min(1).max(120).optional(),
|
|
port: z.string().optional(),
|
|
ports: z.string().optional(),
|
|
env: z.string().optional(),
|
|
volumes: z.string().optional(),
|
|
network: z.string().optional(),
|
|
restartPolicy: z.string().optional(),
|
|
command: z.string().optional(),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
if (data.mode === "template" && !data.appId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["appId"],
|
|
message: "appId is required for template installs",
|
|
});
|
|
}
|
|
|
|
if (data.mode === "custom") {
|
|
if (!data.appName) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["appName"],
|
|
message: "appName is required for custom installs",
|
|
});
|
|
}
|
|
|
|
if (!data.image) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["image"],
|
|
message: "image is required for custom installs",
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
export async function appsRoutes(app: FastifyInstance) {
|
|
const appReadAccess = [
|
|
authenticate,
|
|
requireApiKeyPermission("read:containers"),
|
|
];
|
|
const appWriteAccess = [
|
|
authenticate,
|
|
requireApiKeyPermission("write:containers"),
|
|
];
|
|
|
|
app.get("/templates", { preHandler: appReadAccess }, async (_req, reply) => {
|
|
return reply.send({ success: true, data: APP_TEMPLATES });
|
|
});
|
|
|
|
app.get(
|
|
"/catalog/sources",
|
|
{ preHandler: appReadAccess },
|
|
async (_req, reply) => {
|
|
return reply.send({ success: true, data: CATALOG_SOURCE_OPTIONS });
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/catalog/sync",
|
|
{ preHandler: appReadAccess },
|
|
async (req, reply) => {
|
|
const body = z
|
|
.object({
|
|
source: z.enum([
|
|
"local",
|
|
"auto-detect",
|
|
"manifest-url",
|
|
"github-archive",
|
|
]),
|
|
url: z.string().url().optional(),
|
|
})
|
|
.safeParse(req.body);
|
|
|
|
if (!body.success) {
|
|
return reply
|
|
.status(400)
|
|
.send({ success: false, error: body.error.flatten() });
|
|
}
|
|
|
|
try {
|
|
const result = await getCatalogTemplates(
|
|
body.data.source as CatalogSourceMode,
|
|
body.data.url,
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
data: result.templates,
|
|
meta: result.meta,
|
|
});
|
|
} catch (error: any) {
|
|
return reply.status(400).send({
|
|
success: false,
|
|
error: error?.message || "Failed to sync remote catalog",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get("/installs", { preHandler: appReadAccess }, async (req, reply) => {
|
|
const { serverId, includeRuntime } = req.query as {
|
|
serverId?: string;
|
|
includeRuntime?: string;
|
|
};
|
|
const shouldIncludeRuntime = includeRuntime !== "false";
|
|
|
|
const installs = await prisma.appInstall.findMany({
|
|
where: {
|
|
...(serverId ? { serverId } : {}),
|
|
server: { organizationId: req.organizationId! },
|
|
status: { not: "REMOVED" },
|
|
},
|
|
orderBy: { installedAt: "desc" },
|
|
include: {
|
|
server: { select: { name: true, ip: true, organizationId: true } },
|
|
},
|
|
});
|
|
|
|
// Defensive de-dupe: if multiple install rows point to the same container name
|
|
// on the same server (e.g. repeated installs or previously removed rows),
|
|
// only return the newest one so the UI does not show duplicate cards.
|
|
const seenContainers = new Set<string>();
|
|
const dedupedInstalls = installs.filter((install) => {
|
|
const name = install.containerName?.trim();
|
|
if (!name) return true;
|
|
const key = `${install.serverId}::${name.toLowerCase()}`;
|
|
if (seenContainers.has(key)) return false;
|
|
seenContainers.add(key);
|
|
return true;
|
|
});
|
|
|
|
const installsWithRuntime = shouldIncludeRuntime
|
|
? await overlayInstallRuntimeStatuses(dedupedInstalls)
|
|
: dedupedInstalls;
|
|
|
|
return reply.send({ success: true, data: installsWithRuntime });
|
|
});
|
|
|
|
app.post("/install", { preHandler: appWriteAccess }, async (req, reply) => {
|
|
const body = installSchema.safeParse(req.body);
|
|
|
|
if (!body.success) {
|
|
return reply
|
|
.status(400)
|
|
.send({ success: false, error: body.error.flatten() });
|
|
}
|
|
|
|
const server = await prisma.server.findUnique({
|
|
where: { id: body.data.serverId },
|
|
});
|
|
|
|
if (!server || server.organizationId !== req.organizationId) {
|
|
return reply
|
|
.status(404)
|
|
.send({ success: false, error: "Server not found" });
|
|
}
|
|
|
|
const selectedEnvironmentId = body.data.environmentId?.trim() || null;
|
|
if (selectedEnvironmentId) {
|
|
const environment = await prisma.environment.findFirst({
|
|
where: {
|
|
id: selectedEnvironmentId,
|
|
serverId: server.id,
|
|
project: { organizationId: req.organizationId! },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!environment) {
|
|
return reply.status(400).send({
|
|
success: false,
|
|
error: "Selected environment was not found for this server",
|
|
});
|
|
}
|
|
}
|
|
|
|
const dockerStatus = await ssh.getDockerRuntimeStatus(server);
|
|
if (!dockerStatus.available) {
|
|
return reply.status(400).send({
|
|
success: false,
|
|
error:
|
|
dockerStatus.reason ||
|
|
"Docker is not available on the selected server",
|
|
});
|
|
}
|
|
|
|
let template: AppTemplate | undefined;
|
|
let preset: AppTemplatePreset | undefined;
|
|
|
|
if (body.data.mode === "template") {
|
|
template =
|
|
APP_TEMPLATES.find((item) => item.id === body.data.appId) ??
|
|
body.data.templateSnapshot;
|
|
|
|
if (!template) {
|
|
return reply
|
|
.status(404)
|
|
.send({ success: false, error: "App template not found" });
|
|
}
|
|
|
|
preset = mergeTemplatePreset(template, body.data.presetId);
|
|
|
|
if (body.data.presetId && !preset) {
|
|
return reply
|
|
.status(404)
|
|
.send({ success: false, error: "Preset not found for app template" });
|
|
}
|
|
}
|
|
|
|
const imageBase =
|
|
body.data.mode === "custom"
|
|
? body.data.image!.trim()
|
|
: body.data.image?.trim() || template!.image;
|
|
const finalImage = body.data.tag?.trim()
|
|
? `${imageBase}:${body.data.tag.trim()}`
|
|
: imageBase;
|
|
const finalPorts = firstDefined(
|
|
body.data.ports,
|
|
body.data.port,
|
|
preset?.defaultPort,
|
|
template?.defaultPort,
|
|
"",
|
|
);
|
|
const finalEnv = firstDefined(
|
|
body.data.env,
|
|
preset?.defaultEnv,
|
|
template?.defaultEnv,
|
|
"",
|
|
);
|
|
let finalVolumes = firstDefined(
|
|
body.data.volumes,
|
|
preset?.defaultVolumes,
|
|
template?.defaultVolumes,
|
|
"",
|
|
);
|
|
const finalNetwork = firstDefined(
|
|
body.data.network,
|
|
preset?.defaultNetwork,
|
|
template?.defaultNetwork,
|
|
"bridge",
|
|
);
|
|
const finalRestartPolicy = firstDefined(
|
|
body.data.restartPolicy,
|
|
preset?.restartPolicy,
|
|
template?.restartPolicy,
|
|
"unless-stopped",
|
|
);
|
|
const finalCommand = firstDefined(
|
|
body.data.command,
|
|
preset?.defaultCommand,
|
|
template?.defaultCommand,
|
|
"",
|
|
);
|
|
const finalAppName =
|
|
body.data.mode === "custom"
|
|
? body.data.appName!.trim()
|
|
: body.data.appName?.trim() || template!.name;
|
|
const finalAppId =
|
|
body.data.mode === "custom"
|
|
? `custom-${slugify(finalAppName) || "image"}`
|
|
: template!.id;
|
|
const containerName =
|
|
body.data.containerName?.trim() ||
|
|
`${slugify(finalAppName) || "app"}-${Date.now()}`;
|
|
|
|
if (
|
|
body.data.mode === "template" &&
|
|
!body.data.volumes?.trim() &&
|
|
finalAppId
|
|
) {
|
|
finalVolumes =
|
|
resolveIsolatedTemplateVolumes(finalAppId, containerName) ||
|
|
finalVolumes;
|
|
}
|
|
|
|
const install = await prisma.appInstall.create({
|
|
data: {
|
|
appId: finalAppId,
|
|
appName: finalAppName,
|
|
serverId: server.id,
|
|
containerName,
|
|
port: finalPorts || null,
|
|
status: "INSTALLING",
|
|
},
|
|
});
|
|
|
|
setImmediate(async () => {
|
|
try {
|
|
const dockerId = await ssh.runContainer(server, {
|
|
name: containerName,
|
|
image: finalImage,
|
|
ports: finalPorts,
|
|
env: finalEnv,
|
|
volumes: finalVolumes,
|
|
network: finalNetwork,
|
|
restartPolicy: finalRestartPolicy,
|
|
command: finalCommand,
|
|
mountValidation: resolveAppMountValidation(finalAppId),
|
|
});
|
|
|
|
await upsertAppInstallerContainer({
|
|
serverId: server.id,
|
|
environmentId: selectedEnvironmentId,
|
|
containerName,
|
|
image: finalImage,
|
|
dockerId,
|
|
ports: finalPorts,
|
|
env: finalEnv,
|
|
volumes: finalVolumes,
|
|
restartPolicy: finalRestartPolicy,
|
|
});
|
|
|
|
await prisma.appInstall.update({
|
|
where: { id: install.id },
|
|
data: { status: "RUNNING" },
|
|
});
|
|
|
|
await auditLog({
|
|
userId: req.userId,
|
|
organizationId: req.organizationId,
|
|
serverId: server.id,
|
|
action: "APP_INSTALL",
|
|
category: "SYSTEM",
|
|
level: "SUCCESS",
|
|
message: `App "${finalAppName}" installed on "${server.name}"`,
|
|
meta: {
|
|
mode: body.data.mode,
|
|
image: finalImage,
|
|
presetId: body.data.presetId,
|
|
ports: finalPorts,
|
|
network: finalNetwork,
|
|
},
|
|
});
|
|
|
|
if (req.organizationId) {
|
|
await dispatchRuntimeNotification({
|
|
organizationId: req.organizationId,
|
|
action: "app_deploy",
|
|
title: `App deployed: ${finalAppName}`,
|
|
message: `App ${finalAppName} was deployed successfully on ${server.name} using image ${finalImage}.`,
|
|
serverId: server.id,
|
|
resourceType: "app_install",
|
|
resourceId: install.id,
|
|
metadata: {
|
|
installId: install.id,
|
|
appId: finalAppId,
|
|
appName: finalAppName,
|
|
image: finalImage,
|
|
mode: body.data.mode,
|
|
presetId: body.data.presetId ?? null,
|
|
serverId: server.id,
|
|
serverName: server.name,
|
|
},
|
|
});
|
|
}
|
|
} catch (err: any) {
|
|
await prisma.appInstall.update({
|
|
where: { id: install.id },
|
|
data: { status: "FAILED", error: err.message },
|
|
});
|
|
|
|
await auditLog({
|
|
userId: req.userId,
|
|
organizationId: req.organizationId,
|
|
serverId: server.id,
|
|
action: "APP_INSTALL_FAILED",
|
|
category: "SYSTEM",
|
|
level: "ERROR",
|
|
message: `App "${finalAppName}" install failed on "${server.name}": ${err.message}`,
|
|
meta: {
|
|
mode: body.data.mode,
|
|
image: finalImage,
|
|
presetId: body.data.presetId,
|
|
},
|
|
});
|
|
|
|
if (req.organizationId) {
|
|
await dispatchRuntimeNotification({
|
|
organizationId: req.organizationId,
|
|
action: "app_build_error",
|
|
title: `App deploy failed: ${finalAppName}`,
|
|
message: `App ${finalAppName} failed to deploy on ${server.name}. ${err.message}`,
|
|
serverId: server.id,
|
|
resourceType: "app_install",
|
|
resourceId: install.id,
|
|
metadata: {
|
|
installId: install.id,
|
|
appId: finalAppId,
|
|
appName: finalAppName,
|
|
image: finalImage,
|
|
mode: body.data.mode,
|
|
presetId: body.data.presetId ?? null,
|
|
serverId: server.id,
|
|
serverName: server.name,
|
|
error: err.message,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return reply.status(202).send({
|
|
success: true,
|
|
data: install,
|
|
message: "Installation started",
|
|
});
|
|
});
|
|
|
|
app.get(
|
|
"/installs/:id",
|
|
{ preHandler: appReadAccess },
|
|
async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
|
|
const install = await prisma.appInstall.findUnique({
|
|
where: { id },
|
|
include: {
|
|
server: { select: { name: true, ip: true, organizationId: true } },
|
|
},
|
|
});
|
|
|
|
if (!install || install.server.organizationId !== req.organizationId) {
|
|
return reply
|
|
.status(404)
|
|
.send({ success: false, error: "Install not found" });
|
|
}
|
|
|
|
return reply.send({ success: true, data: install });
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
"/installs/:id/runtime-details",
|
|
{ preHandler: appReadAccess },
|
|
async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const { lines = "300" } = req.query as { lines?: string };
|
|
|
|
const install = await prisma.appInstall.findUnique({
|
|
where: { id },
|
|
include: { server: true },
|
|
});
|
|
|
|
if (!install || install.server.organizationId !== req.organizationId) {
|
|
return reply
|
|
.status(404)
|
|
.send({ success: false, error: "Install not found" });
|
|
}
|
|
|
|
if (!install.containerName) {
|
|
return reply.status(400).send({
|
|
success: false,
|
|
error: "Install has no container name to inspect",
|
|
});
|
|
}
|
|
|
|
const basePayload = {
|
|
container: {
|
|
id: install.id,
|
|
name: install.containerName,
|
|
image: install.appId,
|
|
status: install.status,
|
|
dockerId: null,
|
|
serverId: install.serverId,
|
|
},
|
|
server: {
|
|
id: install.server.id,
|
|
name: install.server.name,
|
|
ip: install.server.ip,
|
|
},
|
|
logs: "",
|
|
inspect: {},
|
|
stats: emptyRuntimeStats(),
|
|
processes: [] as ssh.DockerContainerProcess[],
|
|
diagnostics: {
|
|
primary: "OK" as
|
|
| "OK"
|
|
| "CONTAINER_NOT_FOUND"
|
|
| "INSPECT_FAILED"
|
|
| "RUNTIME_UNAVAILABLE",
|
|
runtimeMessage: null as string | null,
|
|
inspect: { available: true, error: null as string | null },
|
|
logs: { available: true, error: null as string | null },
|
|
stats: { available: true, error: null as string | null },
|
|
processes: { available: true, error: null as string | null },
|
|
},
|
|
};
|
|
|
|
try {
|
|
const inspect = (await ssh.dockerInspect(
|
|
install.server,
|
|
install.containerName,
|
|
)) as DockerInspectRuntime;
|
|
|
|
const [logsResult, statsResult, processesResult] =
|
|
await Promise.allSettled([
|
|
ssh.dockerLogs(
|
|
install.server,
|
|
install.containerName,
|
|
parseInt(lines, 10) || 300,
|
|
),
|
|
ssh.dockerStats(install.server, install.containerName),
|
|
ssh.dockerTop(install.server, install.containerName),
|
|
]);
|
|
|
|
const logs = logsResult.status === "fulfilled" ? logsResult.value : "";
|
|
const stats =
|
|
statsResult.status === "fulfilled"
|
|
? statsResult.value
|
|
: emptyRuntimeStats();
|
|
const processes =
|
|
processesResult.status === "fulfilled" ? processesResult.value : [];
|
|
|
|
const diagnostics = {
|
|
primary: "OK" as const,
|
|
runtimeMessage: null,
|
|
inspect: { available: true, error: null },
|
|
logs: {
|
|
available: logsResult.status === "fulfilled",
|
|
error:
|
|
logsResult.status === "rejected"
|
|
? logsResult.reason instanceof Error
|
|
? logsResult.reason.message
|
|
: "Failed to load logs"
|
|
: null,
|
|
},
|
|
stats: {
|
|
available: statsResult.status === "fulfilled",
|
|
error:
|
|
statsResult.status === "rejected"
|
|
? statsResult.reason instanceof Error
|
|
? statsResult.reason.message
|
|
: "Failed to load stats"
|
|
: null,
|
|
},
|
|
processes: {
|
|
available: processesResult.status === "fulfilled",
|
|
error:
|
|
processesResult.status === "rejected"
|
|
? processesResult.reason instanceof Error
|
|
? processesResult.reason.message
|
|
: "Failed to load processes"
|
|
: null,
|
|
},
|
|
};
|
|
|
|
return reply.send({
|
|
success: true,
|
|
data: {
|
|
container: {
|
|
id: install.id,
|
|
name: install.containerName,
|
|
image: inspect.Config?.Image || install.appId,
|
|
status: mapRuntimeStatus(inspect.State?.Status),
|
|
dockerId: inspect.Id?.trim() || null,
|
|
serverId: install.serverId,
|
|
},
|
|
server: {
|
|
id: install.server.id,
|
|
name: install.server.name,
|
|
ip: install.server.ip,
|
|
},
|
|
logs,
|
|
inspect,
|
|
stats,
|
|
processes,
|
|
diagnostics,
|
|
},
|
|
});
|
|
} catch (err: any) {
|
|
const message = err?.message || "Failed to inspect app runtime";
|
|
const primary = classifyRuntimeError(message);
|
|
return reply.send({
|
|
success: true,
|
|
data: {
|
|
...basePayload,
|
|
diagnostics: {
|
|
...basePayload.diagnostics,
|
|
primary,
|
|
runtimeMessage: message,
|
|
inspect: {
|
|
available: false,
|
|
error: message,
|
|
},
|
|
logs: {
|
|
available: false,
|
|
error:
|
|
primary === "CONTAINER_NOT_FOUND"
|
|
? "Logs unavailable because the container could not be found"
|
|
: "Logs unavailable because docker inspect failed",
|
|
},
|
|
stats: {
|
|
available: false,
|
|
error:
|
|
primary === "CONTAINER_NOT_FOUND"
|
|
? "Stats unavailable because the container could not be found"
|
|
: "Stats unavailable because docker inspect failed",
|
|
},
|
|
processes: {
|
|
available: false,
|
|
error:
|
|
primary === "CONTAINER_NOT_FOUND"
|
|
? "Processes unavailable because the container could not be found"
|
|
: "Processes unavailable because docker inspect failed",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/installs/:id/action",
|
|
{ preHandler: appWriteAccess },
|
|
async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const body = z
|
|
.object({ action: z.enum(["start", "stop", "restart"]) })
|
|
.safeParse(req.body);
|
|
|
|
if (!body.success) {
|
|
return reply
|
|
.status(400)
|
|
.send({ success: false, error: body.error.flatten() });
|
|
}
|
|
|
|
const install = await prisma.appInstall.findUnique({
|
|
where: { id },
|
|
include: { server: true },
|
|
});
|
|
|
|
if (!install) {
|
|
return reply
|
|
.status(404)
|
|
.send({ success: false, error: "Install not found" });
|
|
}
|
|
|
|
if (!install.containerName) {
|
|
return reply.status(400).send({
|
|
success: false,
|
|
error: "Install has no container name to manage",
|
|
});
|
|
}
|
|
|
|
try {
|
|
await ssh.dockerAction(
|
|
install.server,
|
|
install.containerName,
|
|
body.data.action,
|
|
);
|
|
|
|
await prisma.appInstall.update({
|
|
where: { id: install.id },
|
|
data: {
|
|
status: mapLifecycleActionToInstallStatus(body.data.action),
|
|
error: null,
|
|
},
|
|
});
|
|
|
|
await auditLog({
|
|
userId: req.userId,
|
|
serverId: install.serverId,
|
|
action: `APP_${body.data.action.toUpperCase()}`,
|
|
category: "SYSTEM",
|
|
level: "INFO",
|
|
message: `App "${install.appName}" -> ${body.data.action} on "${install.server.name}"`,
|
|
});
|
|
|
|
if (
|
|
req.organizationId &&
|
|
(body.data.action === "start" || body.data.action === "restart")
|
|
) {
|
|
await dispatchRuntimeNotification({
|
|
organizationId: req.organizationId,
|
|
action: "app_deploy",
|
|
title: `App ${body.data.action}ed: ${install.appName}`,
|
|
message: `App ${install.appName} was ${body.data.action}ed on ${install.server.name}.`,
|
|
serverId: install.serverId,
|
|
resourceType: "app_install",
|
|
resourceId: install.id,
|
|
metadata: {
|
|
installId: install.id,
|
|
appId: install.appId,
|
|
appName: install.appName,
|
|
action: body.data.action,
|
|
serverId: install.serverId,
|
|
serverName: install.server.name,
|
|
},
|
|
});
|
|
}
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: `App ${body.data.action} executed successfully`,
|
|
});
|
|
} catch (err: any) {
|
|
return reply.status(500).send({
|
|
success: false,
|
|
error: err?.message || `Failed to ${body.data.action} app`,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/installs/:id/rebuild",
|
|
{ preHandler: appWriteAccess },
|
|
async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
try {
|
|
const result = await rebuildAppInstall({
|
|
installId: id,
|
|
userId: req.userId,
|
|
organizationId: req.organizationId,
|
|
});
|
|
|
|
return reply.send({
|
|
success: true,
|
|
data: result.updated,
|
|
message: result.pulledLatestImage
|
|
? "App rebuilt successfully with the latest image"
|
|
: "App rebuilt successfully using the cached image",
|
|
});
|
|
} catch (err: unknown) {
|
|
return reply.status(500).send({
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "Failed to rebuild app",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
app.delete(
|
|
"/installs/:id",
|
|
{ preHandler: appWriteAccess },
|
|
async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
|
|
const install = await prisma.appInstall.findUnique({
|
|
where: { id },
|
|
include: { server: true },
|
|
});
|
|
|
|
if (!install) {
|
|
return reply
|
|
.status(404)
|
|
.send({ success: false, error: "Install not found" });
|
|
}
|
|
|
|
if (install.containerName) {
|
|
try {
|
|
await ssh.dockerAction(install.server, install.containerName, "stop");
|
|
} catch {
|
|
// Best effort only.
|
|
}
|
|
|
|
try {
|
|
await ssh.dockerAction(install.server, install.containerName, "rm");
|
|
} catch (err: any) {
|
|
const message = err?.message || "Failed to remove app container";
|
|
const runtimeError = classifyRuntimeError(message);
|
|
|
|
if (runtimeError !== "CONTAINER_NOT_FOUND") {
|
|
return reply.status(500).send({
|
|
success: false,
|
|
error: message,
|
|
});
|
|
}
|
|
}
|
|
|
|
await prisma.container.deleteMany({
|
|
where: {
|
|
serverId: install.serverId,
|
|
name: { equals: install.containerName, mode: "insensitive" },
|
|
},
|
|
});
|
|
}
|
|
|
|
// Remove all duplicate rows for the same container on the same server.
|
|
// Otherwise, deleting one id can reveal an older duplicate in list output.
|
|
if (install.containerName) {
|
|
await prisma.appInstall.deleteMany({
|
|
where: {
|
|
serverId: install.serverId,
|
|
containerName: {
|
|
equals: install.containerName,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
});
|
|
} else {
|
|
await prisma.appInstall.delete({
|
|
where: { id },
|
|
});
|
|
}
|
|
|
|
await auditLog({
|
|
userId: req.userId,
|
|
serverId: install.serverId,
|
|
action: "APP_REMOVE",
|
|
category: "SYSTEM",
|
|
level: "WARNING",
|
|
message: `App "${install.appName}" removed from "${install.server.name}"`,
|
|
});
|
|
|
|
return reply.send({ success: true, message: "App removed" });
|
|
},
|
|
);
|
|
}
|