Files
doktainer-upstream/src/server/routes/containers.ts
T

4249 lines
125 KiB
TypeScript

import { FastifyInstance, FastifyReply } from "fastify";
import yaml from "js-yaml";
import { posix as pathPosix } from "path";
import { z } from "zod";
import prisma from "../lib/prisma";
import {
ContainerDeployMode,
ContainerSourceType,
ContainerStatus,
RepositoryVisibility,
} from "@prisma/client";
import { decrypt, encrypt } from "../lib/crypto";
import {
authenticate,
requireApiKeyPermission,
requireRole,
} from "../middleware/auth";
import { auditLog } from "../services/audit.service";
import { dispatchRuntimeNotification } from "../services/notification.service";
import {
appendProcessJobLog,
cancelProcessJob,
createProcessJob,
getProcessJob,
serializeProcessJob,
subscribeProcessJob,
updateProcessJob,
} from "../services/process-job.service";
import * as ssh from "../services/ssh.service";
import { withCommandLogSink } from "../services/ssh.service";
import { rebuildAppInstall } from "../services/app-install-rebuild.service";
import {
createDeployment,
getDeployment,
listDeployments,
updateDeployment,
} from "../services/deployment.service";
import { rollbackContainerToDeployment } from "../services/deployment-rollback.service";
import {
acquireDeploymentLock,
releaseDeploymentLock,
startDeploymentLockHeartbeat,
} from "../services/deployment-lock.service";
import { sanitizeDeploymentError } from "../services/deployment-error.service";
import { reconcileInterruptedRebuild } from "../services/deployment-recovery.service";
import {
formatDockerInspectMountBindings,
type DockerInspectMount as DockerInspectRuntimeMount,
} from "../services/docker-inspect-format";
const DeploySourceTypeSchema = z.enum([
"APP_INSTALLER",
"MANUAL",
"GIT_CLONE",
"GIT_PROVIDER",
]);
const DeployModeSchema = z.enum(["IMAGE", "DOCKERFILE", "COMPOSE"]);
const GitBuildTypeSchema = z.enum([
"NIXPACKS",
"HEROKU_BUILDPACKS",
"PAKETO_BUILDPACKS",
"STATIC",
"DOCKERFILE",
"COMPOSE",
]);
const RepositoryVisibilitySchema = z.enum(["PUBLIC", "PRIVATE"]);
const DeploySchema = z.object({
name: z
.string()
.min(1)
.max(64)
.regex(/^[a-z0-9_-]+$/i),
image: z.string().trim().max(255).optional().or(z.literal("")),
serverId: z.string(),
environmentId: z.string().trim().max(64).optional().or(z.literal("")),
ports: z.string().default(""),
env: z.string().default(""),
restartPolicy: z.string().default("unless-stopped"),
volumes: z.string().optional(),
sourceType: DeploySourceTypeSchema.default("MANUAL"),
deployMode: DeployModeSchema.optional(),
buildType: GitBuildTypeSchema.optional(),
networkId: z.string().trim().max(64).optional().or(z.literal("")),
repoUrl: z.string().trim().url().max(2048).optional().or(z.literal("")),
repoBranch: z.string().trim().max(120).optional().or(z.literal("")),
repoVisibility: RepositoryVisibilitySchema.default("PUBLIC"),
accessToken: z.string().trim().max(512).optional().or(z.literal("")),
gitProviderId: z.string().trim().max(64).optional().or(z.literal("")),
buildPath: z.string().trim().max(512).optional().or(z.literal("")),
startCommand: z.string().trim().max(2000).optional().or(z.literal("")),
portOverride: z.string().trim().max(120).optional().or(z.literal("")),
publishDirectory: z.string().trim().max(512).optional().or(z.literal("")),
composeContent: z.string().max(500_000).optional().or(z.literal("")),
composeFilePath: z.string().trim().max(512).optional().or(z.literal("")),
composeEnvFiles: z
.array(
z.object({
path: z.string().trim().min(1).max(512),
content: z.string().max(200_000),
}),
)
.max(10)
.optional()
.default([]),
dockerfileContent: z.string().max(300_000).optional().or(z.literal("")),
dockerfilePath: z.string().trim().max(512).optional().or(z.literal("")),
dockerContextPath: z.string().trim().max(512).optional().or(z.literal("")),
imageTag: z.string().trim().max(255).optional().or(z.literal("")),
});
const ContainerPathSchema = z.object({
path: z.string().min(1).max(2048),
});
const ContainerWriteFileSchema = z.object({
path: z.string().min(1).max(2048),
content: z.string().max(500_000),
});
const ContainerProjectEnvWriteSchema = z.object({
path: z.string().min(1).max(2048),
content: z.string().max(500_000),
source: z.enum(["container", "project"]),
});
const ContainerExecSchema = z.object({
command: z.string().trim().min(1).max(2048),
});
const ContainerCreateFileSchema = z.object({
path: z.string().min(1).max(2048),
content: z.string().max(500_000).optional(),
});
const ContainerCreateFolderSchema = z.object({
path: z.string().min(1).max(2048),
});
const ContainerRenamePathSchema = z.object({
path: z.string().min(1).max(2048),
newPath: z.string().min(1).max(2048),
});
const CONTAINER_UPLOAD_CONTENT_BASE64_MAX_LENGTH = 8_000_000;
const CONTAINER_UPLOAD_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
const CONTAINER_METRICS_CACHE_TTL_MS = 5_000;
const DOCKER_TIMING_SAMPLE_LIMIT = 100;
type DockerCommandCategory = "list" | "logs" | "inspect" | "stats" | "top";
type DockerCommandStatus = "success" | "error";
type DockerCommandTimingEvent = {
endpoint: string;
category: DockerCommandCategory;
serverId: string;
serverName?: string;
containerId?: string;
status: DockerCommandStatus;
durationMs: number;
timedOut: boolean;
};
type DockerCommandTimingSink = (event: DockerCommandTimingEvent) => void;
const dockerTimingStats = new Map<
string,
{ count: number; samples: number[] }
>();
function isLikelyTimeout(error: unknown) {
const message = error instanceof Error ? error.message : String(error ?? "");
return /timed?\s*out|timeout|aborted|abort/i.test(message);
}
function percentile95(samples: number[]) {
if (samples.length === 0) return 0;
const sorted = [...samples].sort((left, right) => left - right);
const index = Math.min(
sorted.length - 1,
Math.ceil(sorted.length * 0.95) - 1,
);
return sorted[index] ?? 0;
}
function recordDockerCommandTiming(
app: FastifyInstance,
event: DockerCommandTimingEvent,
) {
const key = `${event.endpoint}:${event.category}:${event.status}`;
const current = dockerTimingStats.get(key) ?? { count: 0, samples: [] };
const samples = [...current.samples, event.durationMs].slice(
-DOCKER_TIMING_SAMPLE_LIMIT,
);
const count = current.count + 1;
dockerTimingStats.set(key, { count, samples });
app.log.info(
{
event: "docker_command_timing",
endpoint: event.endpoint,
category: event.category,
serverId: event.serverId,
serverName: event.serverName,
containerId: event.containerId,
status: event.status,
durationMs: event.durationMs,
timedOut: event.timedOut,
count,
p95DurationMs: percentile95(samples),
sampleSize: samples.length,
},
"Docker command timing",
);
}
async function measureDockerCommand<T>(
sink: DockerCommandTimingSink | undefined,
event: Omit<DockerCommandTimingEvent, "durationMs" | "status" | "timedOut">,
operation: () => Promise<T>,
) {
const startedAt = Date.now();
try {
const result = await operation();
sink?.({
...event,
status: "success",
durationMs: Date.now() - startedAt,
timedOut: false,
});
return result;
} catch (error) {
sink?.({
...event,
status: "error",
durationMs: Date.now() - startedAt,
timedOut: isLikelyTimeout(error),
});
throw error;
}
}
export type ContainerMetricsPayload = {
container: {
id: string;
name: string;
status: ContainerStatus;
dockerId: string | null;
serverId: string;
};
stats: Awaited<ReturnType<typeof ssh.dockerStats>>;
sampledAt: string;
};
type ContainerMetricsCacheEntry = {
expiresAt: number;
promise: Promise<ContainerMetricsPayload>;
};
const containerMetricsCache = new Map<string, ContainerMetricsCacheEntry>();
type ReadContainerMetricsWithCacheArgs = {
organizationId?: string;
serverId: string;
serverName?: string;
containerId: string;
containerName: string;
containerStatus: ContainerStatus;
dockerId: string | null;
runtimeId: string;
loadStats: () => Promise<Awaited<ReturnType<typeof ssh.dockerStats>>>;
};
function getContainerMetricsCacheKey(args: {
organizationId?: string;
serverId: string;
containerId: string;
runtimeId: string;
}) {
return [
args.organizationId ?? "unknown-organization",
args.serverId,
args.containerId,
args.runtimeId,
].join(":");
}
export function invalidateContainerMetricsCache(args: {
organizationId?: string;
serverId: string;
containerId: string;
}) {
const prefix = [
args.organizationId ?? "unknown-organization",
args.serverId,
args.containerId,
"",
].join(":");
for (const key of containerMetricsCache.keys()) {
if (key.startsWith(prefix)) {
containerMetricsCache.delete(key);
}
}
}
export function clearContainerMetricsCacheForTest() {
containerMetricsCache.clear();
}
export async function readContainerMetricsWithCache({
organizationId,
serverId,
containerId,
containerName,
containerStatus,
dockerId,
runtimeId,
loadStats,
}: ReadContainerMetricsWithCacheArgs): Promise<ContainerMetricsPayload> {
const cacheKey = getContainerMetricsCacheKey({
organizationId,
serverId,
containerId,
runtimeId,
});
const now = Date.now();
const cached = containerMetricsCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.promise;
}
let entry: ContainerMetricsCacheEntry;
const promise = loadStats().then((stats): ContainerMetricsPayload => {
entry.expiresAt = Date.now() + CONTAINER_METRICS_CACHE_TTL_MS;
return {
container: {
id: containerId,
name: containerName,
status: containerStatus,
dockerId,
serverId,
},
stats,
sampledAt: new Date().toISOString(),
};
});
entry = {
expiresAt: Number.POSITIVE_INFINITY,
promise,
};
containerMetricsCache.set(cacheKey, entry);
try {
return await promise;
} catch (error) {
containerMetricsCache.delete(cacheKey);
throw error;
}
}
const ContainerUploadFileSchema = z.object({
directoryPath: z.string().min(1).max(2048),
fileName: z
.string()
.min(1)
.max(255)
.refine((value) => !value.includes("/")),
contentBase64: z
.string()
.min(1)
.max(CONTAINER_UPLOAD_CONTENT_BASE64_MAX_LENGTH),
});
function mapDockerStatus(status: string): ContainerStatus {
const normalized = status.trim().toLowerCase();
if (normalized.startsWith("up ")) return "RUNNING";
if (normalized.startsWith("restarting")) return "STARTING";
if (normalized.startsWith("paused")) return "PAUSED";
if (normalized.startsWith("stopping")) return "STOPPING";
if (
normalized.startsWith("exited") ||
normalized.startsWith("created") ||
normalized.startsWith("removing")
) {
return "STOPPED";
}
return "ERROR";
}
async function deleteRelatedAppInstalls(
serverId: string,
containerName: string,
) {
await prisma.appInstall.deleteMany({
where: {
serverId,
containerName: {
equals: containerName,
mode: "insensitive",
},
},
});
}
function mapDockerInspectStatus(stateStatus: unknown): ContainerStatus {
const normalized = String(stateStatus ?? "")
.trim()
.toLowerCase();
if (normalized === "running") return "RUNNING";
if (normalized === "paused") return "PAUSED";
if (normalized === "restarting") return "STARTING";
if (
normalized === "created" ||
normalized === "exited" ||
normalized === "dead" ||
normalized === "removing"
)
return "STOPPED";
if (!normalized) return "ERROR";
return "ERROR";
}
function isContainerNotFoundError(error: unknown): boolean {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "";
const lower = message.toLowerCase();
return (
lower.includes("no such container") ||
lower.includes("no such object") ||
lower.includes("not found")
);
}
function parseDockerPorts(ports: string): string[] {
return ports
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function extractHostPortFromPortMapping(
value: string,
options: { allowSinglePortAsHost?: boolean } = {},
) {
const trimmed = value.trim().replace(/^['"]|['"]$/g, "");
if (!trimmed) return null;
const dockerPsMatch = trimmed.match(/(?:^|:)(\d+)->/);
if (dockerPsMatch?.[1]) return dockerPsMatch[1];
const mapping = trimmed.split("/")[0]?.trim() ?? trimmed;
const parts = mapping.split(":").filter(Boolean);
if (parts.length === 1) {
return options.allowSinglePortAsHost && /^\d+$/.test(parts[0])
? parts[0]
: null;
}
const hostPort = parts[parts.length - 2] ?? "";
return /^\d+$/.test(hostPort) ? hostPort : null;
}
function extractHostPortsFromPortMappings(
ports: string,
options: { allowSinglePortAsHost?: boolean } = {},
) {
return ports
.split(",")
.map((entry) => extractHostPortFromPortMapping(entry, options))
.filter((port): port is string => Boolean(port));
}
function extractHostPortsFromStoredPorts(value: unknown) {
const ports = Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: typeof value === "string"
? parseDockerPorts(value)
: [];
return ports
.map((entry) => extractHostPortFromPortMapping(entry))
.filter((port): port is string => Boolean(port));
}
function extractComposePublishedHostPorts(composeContent?: string | null) {
const content = toOptionalValue(composeContent);
if (!content) return [];
let parsed: unknown;
try {
parsed = yaml.load(content);
} catch {
return [];
}
if (
typeof parsed !== "object" ||
parsed === null ||
!("services" in parsed)
) {
return [];
}
const services = (parsed as { services?: unknown }).services;
if (typeof services !== "object" || services === null) {
return [];
}
const hostPorts: string[] = [];
for (const service of Object.values(services)) {
if (typeof service !== "object" || service === null) continue;
const ports = (service as { ports?: unknown }).ports;
if (!Array.isArray(ports)) continue;
for (const entry of ports) {
if (typeof entry === "string") {
const hostPort = extractHostPortFromPortMapping(entry);
if (hostPort) hostPorts.push(hostPort);
continue;
}
if (typeof entry !== "object" || entry === null) continue;
const published = (entry as { published?: unknown }).published;
const hostPort =
typeof published === "number" || typeof published === "string"
? String(published).trim()
: "";
if (/^\d+$/.test(hostPort)) hostPorts.push(hostPort);
}
}
return hostPorts;
}
function collectRequestedHostPorts(input: z.infer<typeof DeploySchema>) {
const hostPorts = [
...extractHostPortsFromPortMappings(input.ports),
...extractHostPortsFromPortMappings(input.portOverride ?? "", {
allowSinglePortAsHost: true,
}),
];
if (input.sourceType === "MANUAL" && input.deployMode === "COMPOSE") {
hostPorts.push(...extractComposePublishedHostPorts(input.composeContent));
}
return hostPorts;
}
async function assertRequestedHostPortsAvailable(input: {
deployInput: z.infer<typeof DeploySchema>;
serverId: string;
}) {
const requestedHostPorts = collectRequestedHostPorts(input.deployInput);
if (requestedHostPorts.length === 0) return;
const seen = new Set<string>();
const duplicateRequestedPort = requestedHostPorts.find((port) => {
if (seen.has(port)) return true;
seen.add(port);
return false;
});
if (duplicateRequestedPort) {
throw new Error(
`Host port ${duplicateRequestedPort} is mapped more than once in this deployment.`,
);
}
const containers = await prisma.container.findMany({
where: {
serverId: input.serverId,
status: { not: "ERROR" },
},
select: {
name: true,
ports: true,
},
});
for (const container of containers) {
const usedHostPorts = extractHostPortsFromStoredPorts(container.ports);
const conflictingPort = requestedHostPorts.find((port) =>
usedHostPorts.includes(port),
);
if (conflictingPort) {
throw new Error(
`Host port ${conflictingPort} is already used by container "${container.name}" on the selected server.`,
);
}
}
}
function shouldValidateRequestedContainerName(
input: z.infer<typeof DeploySchema>,
) {
const gitBuildType = resolveGitBuildType(input);
return input.deployMode !== "COMPOSE" && gitBuildType !== "COMPOSE";
}
function normalizeContainerNameForConflict(value: string) {
return value.trim().replace(/^\//, "").toLowerCase();
}
async function assertRequestedContainerNameAvailable(input: {
deployInput: z.infer<typeof DeploySchema>;
serverId: string;
}) {
if (!shouldValidateRequestedContainerName(input.deployInput)) return;
const requestedName = normalizeContainerNameForConflict(
input.deployInput.name,
);
if (!requestedName) return;
const containers = await prisma.container.findMany({
where: {
serverId: input.serverId,
status: { not: "ERROR" },
},
select: {
name: true,
},
});
const conflict = containers.find(
(container) =>
normalizeContainerNameForConflict(container.name) === requestedName,
);
if (conflict) {
throw new Error(
`Container name "${input.deployInput.name}" is already used on the selected server.`,
);
}
}
function parseEnvironmentVariables(env: string): string[] {
return env
.split("\n")
.map((entry) => entry.trim())
.filter((entry) => entry.includes("="));
}
function toOptionalValue(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function toNullableValue(value?: string | null): string | null {
return toOptionalValue(value) ?? null;
}
function normalizeRemotePath(path: string) {
return pathPosix.normalize(path.replace(/\\/g, "/"));
}
function normalizeDeploymentChildPath(
deploymentPath: string,
relativePath?: string | null,
) {
const base = normalizeRemotePath(deploymentPath);
const child = relativePath?.trim()
? pathPosix.normalize(pathPosix.join(base, relativePath, ".env"))
: pathPosix.join(base, ".env");
if (child !== base && !child.startsWith(`${base}/`)) {
return null;
}
return child;
}
function isPathInside(basePath: string, targetPath: string) {
const base = normalizeRemotePath(basePath);
const target = normalizeRemotePath(targetPath);
return target === base || target.startsWith(`${base}/`);
}
type DockerInspectMount = {
Source?: string;
Destination?: string;
RW?: boolean;
};
function getInspectMounts(inspect: unknown): DockerInspectMount[] {
if (
typeof inspect !== "object" ||
inspect === null ||
!("Mounts" in inspect) ||
!Array.isArray(inspect.Mounts)
) {
return [];
}
return inspect.Mounts.filter(
(mount): mount is DockerInspectMount =>
typeof mount === "object" && mount !== null,
);
}
function isUsableMountPath(value?: string) {
return Boolean(value?.trim().startsWith("/"));
}
export function resolveContainerPathToHostMountPath(
containerPath: string,
mounts: DockerInspectMount[],
) {
const normalizedContainerPath = normalizeRemotePath(containerPath);
const matchedMount = mounts
.filter(
(mount) =>
isUsableMountPath(mount.Source) && isUsableMountPath(mount.Destination),
)
.map((mount) => ({
source: normalizeRemotePath(mount.Source!),
destination: normalizeRemotePath(mount.Destination!),
}))
.filter(
(mount) =>
normalizedContainerPath === mount.destination ||
normalizedContainerPath.startsWith(`${mount.destination}/`),
)
.sort((left, right) => right.destination.length - left.destination.length)
.at(0);
if (!matchedMount) return null;
const relativePath =
normalizedContainerPath === matchedMount.destination
? ""
: normalizedContainerPath.slice(matchedMount.destination.length + 1);
return pathPosix.normalize(pathPosix.join(matchedMount.source, relativePath));
}
function getWritableMountRootsForPath(
targetPath: string,
mounts: DockerInspectMount[],
) {
const normalizedTargetPath = normalizeRemotePath(targetPath);
return mounts
.filter(
(mount) =>
mount.RW !== false &&
isUsableMountPath(mount.Source) &&
isUsableMountPath(mount.Destination),
)
.map((mount) => normalizeRemotePath(mount.Source!))
.filter((source) => isPathInside(source, normalizedTargetPath));
}
export function parseStoredVolumeMounts(value: unknown): DockerInspectMount[] {
return parseStoredStringArray(value).flatMap((entry) => {
const [sourceRaw, destinationRaw, modeRaw] = entry
.split(":")
.map((part) => part.trim());
const source = sourceRaw ? normalizeRemotePath(sourceRaw) : "";
const destination = destinationRaw
? normalizeRemotePath(destinationRaw)
: "";
if (!source.startsWith("/") || !destination.startsWith("/")) {
return [];
}
return [
{
Source: source,
Destination: destination,
RW: modeRaw !== "ro",
},
];
});
}
function getInspectWorkingDir(inspect: unknown) {
if (
typeof inspect === "object" &&
inspect !== null &&
"Config" in inspect &&
typeof inspect.Config === "object" &&
inspect.Config !== null &&
"WorkingDir" in inspect.Config &&
typeof inspect.Config.WorkingDir === "string" &&
inspect.Config.WorkingDir.trim()
) {
return inspect.Config.WorkingDir.trim();
}
return null;
}
export function getProjectEnvRuntimeCandidatePaths(
inspect: unknown,
mounts: DockerInspectMount[],
processWorkingDirs: string[] = [],
) {
const candidates = new Set<string>();
const workingDir = getInspectWorkingDir(inspect);
processWorkingDirs.forEach((path) => {
if (isUsableMountPath(path)) {
candidates.add(pathPosix.join(normalizeRemotePath(path), ".env"));
}
});
if (workingDir) {
candidates.add(pathPosix.join(normalizeRemotePath(workingDir), ".env"));
}
mounts.forEach((mount) => {
if (isUsableMountPath(mount.Destination)) {
candidates.add(
pathPosix.join(normalizeRemotePath(mount.Destination!), ".env"),
);
}
});
[
"/app",
"/workspace",
"/var/www/html",
"/bitnami/laravel",
"/laravel/laravel",
"/srv/app",
].forEach((path) => {
candidates.add(pathPosix.join(path, ".env"));
});
return Array.from(candidates).slice(0, 12);
}
function redactSecret(message: string, secret?: string | null) {
if (!secret) return message;
return message.split(secret).join("[REDACTED]");
}
function extractProjectPathFromRepoUrl(repoUrl?: string | null) {
const normalized = toOptionalValue(repoUrl);
if (!normalized) return null;
try {
const parsed = new URL(normalized);
return parsed.pathname.replace(/^\/+/, "").replace(/\.git$/i, "") || null;
} catch {
return null;
}
}
async function getAccessibleContainer(
containerId: string,
organizationId: string | undefined,
) {
return prisma.container.findFirst({
where: {
id: containerId,
server: { organizationId: organizationId! },
},
include: { server: true },
});
}
function validateDeployInput(input: z.infer<typeof DeploySchema>) {
const gitBuildType = resolveGitBuildType(input);
if (input.sourceType === "APP_INSTALLER") {
return "App Installer deployment should be started from the Apps page";
}
if (input.sourceType === "MANUAL" && input.deployMode === "IMAGE") {
if (!toOptionalValue(input.image)) {
return "Image is required for manual image deployment";
}
return null;
}
if (input.sourceType === "MANUAL" && input.deployMode === "COMPOSE") {
if (!toOptionalValue(input.composeContent)) {
return "Compose content is required for manual compose deployment";
}
return null;
}
if (input.sourceType === "MANUAL" && input.deployMode === "DOCKERFILE") {
if (!toOptionalValue(input.dockerfileContent)) {
return "Dockerfile content is required for manual Dockerfile deployment";
}
return null;
}
if (
input.sourceType === "GIT_PROVIDER" &&
!toOptionalValue(input.gitProviderId)
) {
return "Git provider selection is required for provider-based deployment";
}
if (
(input.sourceType === "GIT_CLONE" || input.sourceType === "GIT_PROVIDER") &&
!toOptionalValue(input.repoUrl)
) {
return "Repository URL is required for Git-based deployment";
}
if (
(input.sourceType === "GIT_CLONE" || input.sourceType === "GIT_PROVIDER") &&
!gitBuildType
) {
return "Git-based deployment must use a supported build type";
}
if (
input.composeEnvFiles.length > 0 &&
!(
(input.sourceType === "GIT_CLONE" ||
input.sourceType === "GIT_PROVIDER") &&
gitBuildType === "COMPOSE"
)
) {
return "Compose env overrides are only supported for Git Compose deployment";
}
if (gitBuildType === "STATIC" && !toOptionalValue(input.publishDirectory)) {
return "Publish directory is required for static deployments";
}
if (
input.repoVisibility === "PRIVATE" &&
!toOptionalValue(input.accessToken)
) {
return "Access token is required for private repositories";
}
return null;
}
function resolveGitBuildType(input: z.infer<typeof DeploySchema>) {
if (input.buildType) {
return input.buildType;
}
if (input.deployMode === "DOCKERFILE" || input.deployMode === "COMPOSE") {
return input.deployMode;
}
return null;
}
type DockerInspectRuntime = {
Image?: string;
Config?: {
Image?: string;
Env?: string[];
};
HostConfig?: {
RestartPolicy?: { Name?: string | null };
PortBindings?: Record<
string,
Array<{ HostIp?: string; HostPort?: string }> | null
>;
NetworkMode?: string | null;
};
Mounts?: DockerInspectRuntimeMount[];
};
type DockerPortBindings = Record<
string,
Array<{ HostIp?: string; HostPort?: string }> | null
>;
type GitRedeployBuildType = ssh.GitBuildType;
const GIT_REDEPLOY_BUILD_TYPES = new Set<GitRedeployBuildType>([
"NIXPACKS",
"HEROKU_BUILDPACKS",
"PAKETO_BUILDPACKS",
"STATIC",
"DOCKERFILE",
"COMPOSE",
]);
function isGitRedeploySourceType(
sourceType: ContainerSourceType,
): sourceType is "GIT_CLONE" | "GIT_PROVIDER" {
return sourceType === "GIT_CLONE" || sourceType === "GIT_PROVIDER";
}
function formatEnvLines(env?: string[] | null): string {
return (env ?? []).filter(Boolean).join("\n");
}
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 parseStoredStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
function formatStoredEnvLines(value: unknown): string {
return parseStoredStringArray(value).join("\n");
}
function formatStoredCsv(value: unknown): string {
return parseStoredStringArray(value).join(",");
}
function parseStoredComposeEnvOverrides(
value: unknown,
): ssh.ComposeEnvFileOverride[] {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
if (
!entry ||
typeof entry !== "object" ||
typeof (entry as { path?: unknown }).path !== "string" ||
typeof (entry as { content?: unknown }).content !== "string"
) {
return [];
}
return [
{
path: (entry as { path: string }).path,
content: (entry as { content: string }).content,
},
];
});
}
function resolveStoredGitBuildType(
buildType?: string | null,
deployMode?: ContainerDeployMode | null,
): GitRedeployBuildType {
const normalized = buildType?.trim().toUpperCase();
if (
normalized &&
GIT_REDEPLOY_BUILD_TYPES.has(normalized as GitRedeployBuildType)
) {
return normalized as GitRedeployBuildType;
}
if (deployMode === "COMPOSE" || deployMode === "DOCKERFILE") {
return deployMode;
}
return "NIXPACKS";
}
function resolveDeploymentProjectName(args: {
containerName: string;
source?: {
projectName?: string | null;
deploymentPath?: string | null;
} | null;
}) {
const stored = args.source?.projectName?.trim();
if (stored) return stored;
const deploymentPath = args.source?.deploymentPath?.trim();
if (deploymentPath) {
const basename = pathPosix.basename(deploymentPath.replace(/\\/g, "/"));
if (basename) return basename;
}
return args.containerName;
}
async function resolveContainerRuntimeConfig(container: {
name: string;
dockerId: string | null;
image: string;
ports: unknown;
envVars: unknown;
volumes: unknown;
restartPolicy: string;
server: Awaited<ReturnType<typeof prisma.server.findFirstOrThrow>>;
}) {
try {
const inspect = (await ssh.dockerInspect(
container.server,
container.dockerId || container.name,
)) as DockerInspectRuntime;
return {
image: inspect.Config?.Image?.trim() || container.image,
ports: formatPortBindings(inspect.HostConfig?.PortBindings),
env: formatEnvLines(inspect.Config?.Env),
volumes: formatDockerInspectMountBindings(inspect.Mounts),
network: inspect.HostConfig?.NetworkMode?.trim() || "bridge",
restartPolicy:
inspect.HostConfig?.RestartPolicy?.Name?.trim() ||
container.restartPolicy ||
"unless-stopped",
};
} catch {
return {
image: container.image,
ports: formatStoredCsv(container.ports),
env: formatStoredEnvLines(container.envVars),
volumes: formatStoredCsv(container.volumes),
network: "bridge",
restartPolicy: container.restartPolicy || "unless-stopped",
};
}
}
async function persistExistingDeploymentSourceMetadata(
containers: Array<{ id: string }>,
sourceType: ContainerSourceType,
deployMode: ContainerDeployMode | null,
source: {
projectName?: string | null;
gitProviderId?: string | null;
repoUrl?: string | null;
repoBranch?: string | null;
repoVisibility?: RepositoryVisibility | null;
buildType?: string | null;
buildPath?: string | null;
startCommand?: string | null;
portOverride?: string | null;
publishDirectory?: string | null;
imageTag?: string | null;
accessTokenEnc?: string | null;
composeEnvOverrides?: unknown;
projectPath?: string | null;
composeFilePath?: string | null;
dockerfilePath?: string | null;
dockerContextPath?: string | null;
deploymentPath?: string | null;
} | null,
) {
if (!source) {
return;
}
const composeEnvOverrides = source.composeEnvOverrides
? JSON.parse(JSON.stringify(source.composeEnvOverrides))
: null;
await prisma.$transaction(async (tx) => {
for (const container of containers) {
await tx.container.update({
where: { id: container.id },
data: {
sourceType,
deployMode,
},
});
await tx.containerDeploymentSource.upsert({
where: { containerId: container.id },
update: {
projectName: source.projectName,
gitProviderId: source.gitProviderId,
repoUrl: source.repoUrl,
repoBranch: source.repoBranch,
repoVisibility: source.repoVisibility,
buildType: source.buildType,
buildPath: source.buildPath,
startCommand: source.startCommand,
portOverride: source.portOverride,
publishDirectory: source.publishDirectory,
imageTag: source.imageTag,
accessTokenEnc: source.accessTokenEnc,
composeEnvOverrides,
projectPath: source.projectPath,
composeFilePath: source.composeFilePath,
dockerfilePath: source.dockerfilePath,
dockerContextPath: source.dockerContextPath,
deploymentPath: source.deploymentPath,
},
create: {
containerId: container.id,
projectName: source.projectName,
gitProviderId: source.gitProviderId,
repoUrl: source.repoUrl,
repoBranch: source.repoBranch,
repoVisibility: source.repoVisibility,
buildType: source.buildType,
buildPath: source.buildPath,
startCommand: source.startCommand,
portOverride: source.portOverride,
publishDirectory: source.publishDirectory,
imageTag: source.imageTag,
accessTokenEnc: source.accessTokenEnc,
composeEnvOverrides,
projectPath: source.projectPath,
composeFilePath: source.composeFilePath,
dockerfilePath: source.dockerfilePath,
dockerContextPath: source.dockerContextPath,
deploymentPath: source.deploymentPath,
},
});
}
});
}
function matchesDeploymentTarget(
containerName: string,
targetName: string,
deployMode?: z.infer<typeof DeployModeSchema> | null,
) {
const normalizedContainer = containerName.toLowerCase();
const normalizedTarget = targetName.toLowerCase();
if (deployMode === "COMPOSE") {
return (
normalizedContainer === normalizedTarget ||
normalizedContainer.startsWith(`${normalizedTarget}-`) ||
normalizedContainer.startsWith(`${normalizedTarget}_`)
);
}
return normalizedContainer === normalizedTarget;
}
async function persistDeploymentSourceMetadata(
containers: Array<{ id: string }>,
input: z.infer<typeof DeploySchema>,
deploymentPath?: string,
gitProviderId?: string,
) {
const repoUrl = toNullableValue(input.repoUrl);
const repoBranch = toNullableValue(input.repoBranch);
const buildType = resolveGitBuildType(input);
const buildPath = toNullableValue(input.buildPath);
const startCommand = toNullableValue(input.startCommand);
const portOverride = toNullableValue(input.portOverride);
const publishDirectory = toNullableValue(input.publishDirectory);
const imageTag = toNullableValue(input.imageTag);
const composeFilePath = toNullableValue(input.composeFilePath);
const dockerfilePath = toNullableValue(input.dockerfilePath);
const dockerContextPath = toNullableValue(input.dockerContextPath);
const accessToken = toOptionalValue(input.accessToken);
const projectPath = extractProjectPathFromRepoUrl(repoUrl);
const composeEnvOverrides =
input.composeEnvFiles.length > 0
? JSON.parse(JSON.stringify(input.composeEnvFiles))
: null;
await prisma.$transaction(async (tx) => {
for (const container of containers) {
await tx.container.update({
where: { id: container.id },
data: {
sourceType: input.sourceType as ContainerSourceType,
deployMode: input.deployMode as ContainerDeployMode,
},
});
await tx.containerDeploymentSource.upsert({
where: { containerId: container.id },
update: {
projectName: input.name,
gitProviderId: gitProviderId ?? null,
repoUrl,
repoBranch,
repoVisibility:
repoUrl || gitProviderId
? (input.repoVisibility as RepositoryVisibility)
: null,
buildType:
input.sourceType === "GIT_CLONE" ||
input.sourceType === "GIT_PROVIDER"
? buildType
: null,
buildPath,
startCommand,
portOverride,
publishDirectory,
imageTag,
accessTokenEnc: accessToken ? encrypt(accessToken) : null,
composeEnvOverrides,
projectPath,
composeFilePath,
dockerfilePath,
dockerContextPath,
deploymentPath: toNullableValue(deploymentPath),
},
create: {
containerId: container.id,
projectName: input.name,
gitProviderId: gitProviderId ?? null,
repoUrl,
repoBranch,
repoVisibility:
repoUrl || gitProviderId
? (input.repoVisibility as RepositoryVisibility)
: null,
buildType:
input.sourceType === "GIT_CLONE" ||
input.sourceType === "GIT_PROVIDER"
? buildType
: null,
buildPath,
startCommand,
portOverride,
publishDirectory,
imageTag,
accessTokenEnc: accessToken ? encrypt(accessToken) : null,
composeEnvOverrides,
projectPath,
composeFilePath,
dockerfilePath,
dockerContextPath,
deploymentPath: toNullableValue(deploymentPath),
},
});
}
});
}
async function syncContainersForServers(
servers: Array<{
id: string;
organizationId: string;
name: string;
ip: string;
sshPort: number;
username: string;
authType: "PASSWORD" | "SSH_KEY";
sshKeyEnc: string | null;
passwordEnc: string | null;
status: any;
os: string | null;
location: string | null;
tags: string[];
lastHealthAt: Date | null;
createdAt: Date;
updatedAt: Date;
}>,
userId?: string,
timingSink?: DockerCommandTimingSink,
) {
const summary: Array<{
serverId: string;
serverName: string;
synced: number;
}> = [];
for (const server of servers) {
const pendingNotifications: Array<Promise<unknown>> = [];
let actualContainers;
try {
actualContainers = await measureDockerCommand(
timingSink,
{
endpoint: "containers.sync",
category: "list",
serverId: server.id,
serverName: server.name,
},
() => ssh.listDockerContainers(server),
);
} catch (error) {
throw new Error(
error instanceof Error
? `Failed to sync Docker containers from ${server.name}: ${error.message}`
: `Failed to sync Docker containers from ${server.name}`,
);
}
const existingContainers = await prisma.container.findMany({
where: { serverId: server.id },
orderBy: { createdAt: "asc" },
});
const existingByDockerId = new Map(
existingContainers
.filter((container) => container.dockerId)
.map((container) => [container.dockerId as string, container]),
);
const existingByName = new Map(
existingContainers.map((container) => [container.name, container]),
);
const retainedIds: string[] = [];
await prisma.$transaction(async (tx) => {
for (const actualContainer of actualContainers) {
const existingContainer =
existingByDockerId.get(actualContainer.id) ??
existingByName.get(actualContainer.name);
const payload = {
dockerId: actualContainer.id,
name: actualContainer.name,
image: actualContainer.image,
status: mapDockerStatus(actualContainer.status),
ports: parseDockerPorts(actualContainer.ports),
cpuUsage: actualContainer.cpu === "—" ? null : actualContainer.cpu,
ramUsage:
actualContainer.memory === "—" ? null : actualContainer.memory,
};
if (existingContainer) {
const nextStatus = payload.status;
const previousStatus = existingContainer.status;
const updated = await tx.container.update({
where: { id: existingContainer.id },
data: payload,
});
if (
["RUNNING", "STARTING", "PAUSED"].includes(previousStatus) &&
["STOPPED", "ERROR"].includes(nextStatus)
) {
pendingNotifications.push(
dispatchRuntimeNotification({
organizationId: server.organizationId,
action: "container_crash",
title: `Container stopped unexpectedly on ${server.name}`,
message: `Container ${actualContainer.name} changed from ${previousStatus.toLowerCase()} to ${nextStatus.toLowerCase()} on server ${server.name}.`,
serverId: server.id,
resourceType: "container",
resourceId: updated.id,
metadata: {
containerId: updated.id,
containerName: actualContainer.name,
previousStatus,
currentStatus: nextStatus,
serverId: server.id,
serverName: server.name,
},
}),
);
}
retainedIds.push(updated.id);
continue;
}
const created = await tx.container.create({
data: {
...payload,
serverId: server.id,
},
});
retainedIds.push(created.id);
}
await tx.container.deleteMany({
where: {
serverId: server.id,
...(retainedIds.length > 0 ? { id: { notIn: retainedIds } } : {}),
},
});
});
if (pendingNotifications.length > 0) {
await Promise.allSettled(pendingNotifications);
}
if (userId) {
await auditLog({
userId,
serverId: server.id,
action: "CONTAINER_SYNC",
category: "CONTAINER",
level: "INFO",
message: `Synced ${actualContainers.length} Docker containers from server "${server.name}"`,
});
}
summary.push({
serverId: server.id,
serverName: server.name,
synced: actualContainers.length,
});
}
return summary;
}
function writeProcessJobStreamEvent(
reply: FastifyReply,
event: string,
data: unknown,
) {
if (reply.raw.destroyed || reply.raw.writableEnded) {
return;
}
reply.raw.write(`event: ${event}\n`);
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
}
function buildInternalJobHeaders(headers: Record<string, unknown>) {
const nextHeaders: Record<string, string> = {
"content-type": "application/json",
"x-process-job-sync": "true",
};
const authorization = headers.authorization;
if (typeof authorization === "string") {
nextHeaders.authorization = authorization;
}
const organizationId = headers["x-organization-id"];
if (typeof organizationId === "string") {
nextHeaders["x-organization-id"] = organizationId;
}
return nextHeaders;
}
function isProcessJobCancelling(job: { status: string }) {
return job.status === "cancelling";
}
function parseInjectedResponsePayload(response: {
body: string;
statusCode: number;
}) {
const body = response.body.trim();
if (!body) {
return {};
}
try {
return JSON.parse(body) as {
error?: unknown;
[key: string]: unknown;
};
} catch {
return {
error:
response.statusCode >= 400
? body
: `Backend returned a non-JSON response: ${body}`,
};
}
}
function runInjectedContainerJob(input: {
app: FastifyInstance;
job: Awaited<ReturnType<typeof createProcessJob>>;
method: "POST";
url: string;
headers: Record<string, unknown>;
payload: Record<string, unknown>;
redactSecret?: string;
}) {
void Promise.resolve().then(async () => {
const abortController = new AbortController();
if (isProcessJobCancelling(input.job)) {
await updateProcessJob(input.job, {
status: "cancelled",
error: input.job.cancelReason ?? "Job cancelled",
});
await appendProcessJobLog(input.job, "[job] Cancelled");
return;
}
await updateProcessJob(input.job, {
status: "running",
cancel: (reason) => abortController.abort(reason),
});
await appendProcessJobLog(
input.job,
`[job] Started ${input.job.type}`,
);
try {
const response = await withCommandLogSink(
(chunk) =>
appendProcessJobLog(
input.job,
input.redactSecret
? redactSecret(chunk, input.redactSecret)
: chunk,
),
async () =>
await input.app.inject({
method: input.method,
url: input.url,
headers: {
...buildInternalJobHeaders(input.headers),
"x-process-job-sync": "true",
},
payload: input.payload,
}),
{
runIdPrefix: input.job.id,
signal: abortController.signal,
},
);
if (isProcessJobCancelling(input.job)) {
await updateProcessJob(input.job, {
status: "cancelled",
error: input.job.cancelReason ?? "Job cancelled",
});
await appendProcessJobLog(input.job, "[job] Cancelled");
return;
}
const payload = parseInjectedResponsePayload(response);
if (response.statusCode >= 400) {
const error =
typeof payload?.error === "string"
? payload.error
: JSON.stringify(payload?.error ?? payload);
throw new Error(error || `Job failed with HTTP ${response.statusCode}`);
}
await updateProcessJob(input.job, {
status: "success",
result: payload,
});
await appendProcessJobLog(input.job, "[job] Completed successfully");
} catch (error) {
const message = error instanceof Error ? error.message : "Job failed";
if (
isProcessJobCancelling(input.job) ||
message === "Command cancelled"
) {
await updateProcessJob(input.job, {
status: "cancelled",
error: input.job.cancelReason ?? "Job cancelled",
});
await appendProcessJobLog(input.job, "[job] Cancelled");
return;
}
await updateProcessJob(input.job, {
status: "error",
error: message,
});
await appendProcessJobLog(input.job, `[job] Failed: ${message}`);
}
});
}
function buildDeploymentSnapshot(
body: z.infer<typeof DeploySchema>,
runtimeImage?: string,
) {
return {
sourceType: body.sourceType,
deployMode: body.deployMode,
buildType: body.buildType,
image: runtimeImage || body.image,
ports: body.ports,
env: body.env,
volumes: body.volumes,
restartPolicy: body.restartPolicy,
repoUrl: body.repoUrl,
repoBranch: body.repoBranch,
buildPath: body.buildPath,
composeFilePath: body.composeFilePath,
dockerfilePath: body.dockerfilePath,
dockerContextPath: body.dockerContextPath,
imageTag: body.imageTag,
};
}
export async function containerRoutes(app: FastifyInstance) {
const containerReadAccess = [
authenticate,
requireApiKeyPermission("read:containers"),
];
const containerWriteAccess = [
authenticate,
requireApiKeyPermission("write:containers"),
];
// GET /containers — all containers from DB
const recordDeployment = async (input: {
container: { id: string; serverId: string; image: string };
body: z.infer<typeof DeploySchema>;
trigger: "MANUAL" | "GIT_WEBHOOK" | "APP_INSTALLER";
userId?: string;
organizationId: string;
}) => {
const body = input.body;
return createDeployment({
containerId: input.container.id,
organizationId: input.organizationId,
serverId: input.container.serverId,
userId: input.userId,
status: "SUCCESS",
trigger: input.trigger,
version: body.imageTag || body.repoBranch || input.container.image,
branch: body.repoBranch || null,
image: input.container.image,
configSnapshot: buildDeploymentSnapshot(body, input.container.image),
startedAt: new Date(),
completedAt: new Date(),
});
};
const recordFailedDeployment = async (input: {
container: { id: string; serverId: string; image: string };
body: z.infer<typeof DeploySchema>;
trigger: "MANUAL" | "GIT_WEBHOOK" | "APP_INSTALLER";
userId?: string;
organizationId: string;
error: string;
}) =>
createDeployment({
containerId: input.container.id,
organizationId: input.organizationId,
serverId: input.container.serverId,
userId: input.userId,
status: "FAILED",
trigger: input.trigger,
version: input.body.imageTag || input.body.repoBranch || input.container.image,
branch: input.body.repoBranch || null,
image: input.container.image,
configSnapshot: buildDeploymentSnapshot(input.body, input.container.image),
error: input.error,
startedAt: new Date(),
completedAt: new Date(),
});
app.get(
"/:id/deployments",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const query = req.query as { page?: string; pageSize?: string };
const page = Math.max(1, Number.parseInt(query.page ?? "1", 10) || 1);
const pageSize = Math.min(
50,
Math.max(1, Number.parseInt(query.pageSize ?? "20", 10) || 20),
);
const container = await prisma.container.findFirst({
where: { id, server: { organizationId: req.organizationId! } },
include: { server: true },
});
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
await reconcileInterruptedRebuild({
container,
inspectRuntime: ssh.dockerInspect,
});
const data = await listDeployments({
containerId: id,
organizationId: req.organizationId!,
page,
pageSize,
});
return reply.send({ success: true, data });
},
);
app.get(
"/:id/deployments/:deploymentId",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { id, deploymentId } = req.params as {
id: string;
deploymentId: string;
};
const deployment = await getDeployment({
containerId: id,
deploymentId,
organizationId: req.organizationId!,
});
if (!deployment) {
return reply
.status(404)
.send({ success: false, error: "Deployment not found" });
}
return reply.send({ success: true, data: deployment });
},
);
app.post(
"/:id/deployments/:deploymentId/rollback",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id, deploymentId } = req.params as {
id: string;
deploymentId: string;
};
try {
const result = await rollbackContainerToDeployment({
containerId: id,
deploymentId,
organizationId: req.organizationId!,
userId: req.userId,
});
return reply.send({
success: true,
data: result.updated,
meta: {
rollbackDeploymentId: result.deploymentId,
targetDeploymentId: result.targetDeploymentId,
},
message: "Container rolled back successfully",
});
} catch (error) {
return reply.status(400).send({
success: false,
error: sanitizeDeploymentError(error, {
fallback: "Rollback failed",
}),
});
}
},
);
app.get(
"/jobs/:jobId",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { jobId } = req.params as { jobId: string };
const job = getProcessJob(jobId);
if (
!job ||
job.userId !== req.userId ||
job.organizationId !== req.organizationId
) {
return reply
.status(404)
.send({ success: false, error: "Job not found" });
}
return reply.send({ success: true, data: serializeProcessJob(job) });
},
);
app.post(
"/jobs/:jobId/cancel",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { jobId } = req.params as { jobId: string };
const job = getProcessJob(jobId);
if (
!job ||
job.userId !== req.userId ||
job.organizationId !== req.organizationId
) {
return reply
.status(404)
.send({ success: false, error: "Job not found" });
}
const cancelled = await cancelProcessJob(job);
return reply.send({
success: true,
data: serializeProcessJob(job),
message: cancelled ? "Cancellation requested" : "Job already finished",
});
},
);
app.get(
"/jobs/:jobId/stream",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { jobId } = req.params as { jobId: string };
const job = getProcessJob(jobId);
if (
!job ||
job.userId !== req.userId ||
job.organizationId !== req.organizationId
) {
return reply
.status(404)
.send({ success: false, error: "Job not found" });
}
reply.hijack();
const origin = req.headers.origin;
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
...(typeof origin === "string"
? {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
Vary: "Origin",
}
: {}),
});
writeProcessJobStreamEvent(reply, "status", serializeProcessJob(job));
for (const entry of job.logs) {
writeProcessJobStreamEvent(reply, "log", entry);
}
const unsubscribe = subscribeProcessJob(job, (entry) => {
writeProcessJobStreamEvent(reply, "log", entry);
});
const statusInterval = setInterval(() => {
writeProcessJobStreamEvent(reply, "status", serializeProcessJob(job));
if (
job.status === "success" ||
job.status === "error" ||
job.status === "cancelled"
) {
clearInterval(statusInterval);
unsubscribe();
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
reply.raw.end();
}
}
}, 1000);
req.raw.on("close", () => {
clearInterval(statusInterval);
unsubscribe();
});
},
);
app.post(
"/jobs/deploy",
{ preHandler: containerWriteAccess, bodyLimit: 1024 * 1024 },
async (req, reply) => {
const body = DeploySchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const validationError = validateDeployInput(body.data);
if (validationError) {
return reply
.status(400)
.send({ success: false, error: validationError });
}
const job = await createProcessJob({
type: "container_deploy",
userId: req.userId,
organizationId: req.organizationId,
});
runInjectedContainerJob({
app,
job,
method: "POST",
url: req.url.replace(/\/jobs\/deploy(?:\?.*)?$/, "/"),
headers: req.headers,
payload: body.data as Record<string, unknown>,
redactSecret: toOptionalValue(body.data.accessToken),
});
return reply.status(202).send({
success: true,
data: serializeProcessJob(job),
});
},
);
app.get("/", { preHandler: containerReadAccess }, async (req, reply) => {
const query = req.query as { serverId?: string; status?: string };
const containers = await prisma.container.findMany({
where: {
...(query.serverId ? { serverId: query.serverId } : {}),
server: { organizationId: req.organizationId! },
...(query.status ? { status: query.status as any } : {}),
},
orderBy: { createdAt: "desc" },
include: { server: { select: { name: true, ip: true } } },
});
return reply.send({ success: true, data: containers });
});
app.post(
"/sync",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const body = z
.object({ serverId: z.string().optional() })
.safeParse(req.body ?? {});
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const servers = await prisma.server.findMany({
where: {
...(body.data.serverId ? { id: body.data.serverId } : {}),
organizationId: req.organizationId!,
},
orderBy: { name: "asc" },
});
if (servers.length === 0) {
return reply
.status(404)
.send({ success: false, error: "Server not found" });
}
try {
const summary = await syncContainersForServers(
servers,
req.userId,
(event) => recordDockerCommandTiming(app, event),
);
const containers = await prisma.container.findMany({
where: {
...(body.data.serverId ? { serverId: body.data.serverId } : {}),
server: { organizationId: req.organizationId! },
},
orderBy: { createdAt: "desc" },
include: { server: { select: { name: true, ip: true } } },
});
return reply.send({
success: true,
data: containers,
meta: { summary },
});
} catch (error) {
return reply.status(400).send({
success: false,
error:
error instanceof Error
? error.message
: "Failed to sync Docker containers",
});
}
},
);
app.get(
"/:id/files",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const query = z
.object({ path: z.string().max(2048).optional() })
.safeParse(req.query ?? {});
if (!query.success) {
return reply
.status(400)
.send({ success: false, error: query.error.flatten() });
}
const container = await getAccessibleContainer(id, req.organizationId);
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error: "File Manager only works when the container is running",
});
}
try {
const data = await ssh.listContainerFiles(
container.server,
container.dockerId || container.name,
query.data.path || "/",
);
return reply.send({ success: true, data });
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
app.get(
"/:id/file",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const query = ContainerPathSchema.safeParse(req.query ?? {});
if (!query.success) {
return reply
.status(400)
.send({ success: false, error: query.error.flatten() });
}
const container = await getAccessibleContainer(id, req.organizationId);
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error: "File Manager only works when the container is running",
});
}
try {
const data = await ssh.readContainerFile(
container.server,
container.dockerId || container.name,
query.data.path,
);
return reply.send({ success: true, data });
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
app.get(
"/:id/project-env",
{ preHandler: [...containerReadAccess, requireRole("DEVELOPER")] },
async (req, reply) => {
const { id } = req.params as { id: string };
const container = await prisma.container.findFirst({
where: {
id,
server: { organizationId: req.organizationId! },
},
include: { server: true, deploymentSource: true },
});
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
const deploymentPath = container.deploymentSource?.deploymentPath?.trim();
const buildPathCandidate = deploymentPath
? normalizeDeploymentChildPath(
deploymentPath,
container.deploymentSource?.buildPath,
)
: null;
const rootCandidate = deploymentPath
? normalizeDeploymentChildPath(deploymentPath)
: null;
const candidatePaths = [buildPathCandidate, rootCandidate].filter(
(path): path is string => Boolean(path),
);
const runtimeId = container.dockerId || container.name;
try {
const checkedContainerPaths: string[] = [];
let inspect: unknown = null;
const processWorkingDirs: string[] = [];
let projectMounts = parseStoredVolumeMounts(container.volumes);
try {
inspect = await ssh.dockerInspect(container.server, runtimeId);
} catch {
// Keep stored deployment candidates and common fallback paths.
}
if (container.status === "RUNNING") {
const mainProcessWorkingDir =
await ssh.getContainerMainProcessWorkingDirectory(
container.server,
runtimeId,
);
if (mainProcessWorkingDir) {
processWorkingDirs.push(mainProcessWorkingDir);
}
}
if (inspect) {
projectMounts = [...getInspectMounts(inspect), ...projectMounts];
}
const containerCandidatePaths = getProjectEnvRuntimeCandidatePaths(
inspect,
projectMounts,
processWorkingDirs,
);
if (container.status === "RUNNING") {
for (const candidatePath of containerCandidatePaths) {
checkedContainerPaths.push(candidatePath);
try {
const file = await ssh.readContainerFile(
container.server,
runtimeId,
candidatePath,
);
return reply.send({
success: true,
data: {
found: true,
path: file.path,
content: file.content,
checkedPaths: checkedContainerPaths,
source: "container",
message:
"Project .env file loaded from the active container filesystem.",
},
});
} catch {
// Try the next runtime project path.
}
}
}
const mountCandidatePaths = containerCandidatePaths
.map((path) =>
resolveContainerPathToHostMountPath(path, projectMounts),
)
.filter((path): path is string => Boolean(path));
const data = await ssh.readDeploymentEnvFile(container.server, [
...candidatePaths,
...mountCandidatePaths,
]);
if (data.found) {
return reply.send({
success: true,
data: {
...data,
source: "project",
message:
"Project .env file loaded from deployment path or mounted project path.",
},
});
}
return reply.send({
success: true,
data: {
...data,
checkedPaths: [...data.checkedPaths, ...checkedContainerPaths],
source: "missing",
message:
"No project .env file was found at the checked deployment, mounted project, or container paths.",
},
});
} catch (err: unknown) {
return reply.status(500).send({
success: false,
error:
err instanceof Error ? err.message : "Failed to read .env file",
});
}
},
);
app.put(
"/:id/project-env",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = ContainerProjectEnvWriteSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const container = await prisma.container.findFirst({
where: {
id,
server: { organizationId: req.organizationId! },
},
include: { server: true, deploymentSource: true },
});
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
try {
if (body.data.source === "container") {
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error:
"Saving a container filesystem .env requires the container to be running",
});
}
const data = await ssh.writeContainerFile(
container.server,
container.dockerId || container.name,
body.data.path,
body.data.content,
);
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_ENV_WRITE",
category: "CONTAINER",
level: "INFO",
message: `Updated project .env \"${body.data.path}\" in container \"${container.name}\"`,
});
return reply.send({
success: true,
data: {
path: data.path,
size: data.size,
source: "container",
restartRequired: true,
},
});
}
const deploymentPath =
container.deploymentSource?.deploymentPath?.trim();
const normalizedEnvPath = normalizeRemotePath(body.data.path);
const storedMounts = parseStoredVolumeMounts(container.volumes);
let canWriteProjectEnv = deploymentPath
? isPathInside(deploymentPath, normalizedEnvPath)
: false;
if (!canWriteProjectEnv) {
canWriteProjectEnv =
getWritableMountRootsForPath(normalizedEnvPath, storedMounts)
.length > 0;
}
if (!canWriteProjectEnv) {
try {
const inspect = await ssh.dockerInspect(
container.server,
container.dockerId || container.name,
);
canWriteProjectEnv =
getWritableMountRootsForPath(normalizedEnvPath, [
...getInspectMounts(inspect),
...storedMounts,
]).length > 0;
} catch {
canWriteProjectEnv = false;
}
}
if (!canWriteProjectEnv) {
return reply.status(400).send({
success: false,
error:
"Project .env path is outside the deployment directory or writable mounted project path",
});
}
const data = await ssh.writeDeploymentEnvFile(
container.server,
normalizedEnvPath,
body.data.content,
);
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_ENV_WRITE",
category: "CONTAINER",
level: "INFO",
message: `Updated project .env \"${body.data.path}\" for container \"${container.name}\"`,
});
return reply.send({
success: true,
data: {
path: data.path,
size: data.size,
source: "project",
restartRequired: true,
},
});
} catch (err: unknown) {
return reply.status(500).send({
success: false,
error:
err instanceof Error ? err.message : "Failed to save .env file",
});
}
},
);
app.post(
"/:id/exec",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = ContainerExecSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const container = await getAccessibleContainer(id, req.organizationId);
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error: "Terminal commands require the container to be running",
});
}
try {
const result = await ssh.execContainerCommand(
container.server,
container.dockerId || container.name,
body.data.command,
);
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_TERMINAL_EXEC",
category: "TERMINAL",
level: result.exitCode === 0 ? "INFO" : "WARNING",
message: `Executed in container \"${container.name}\": ${body.data.command.slice(0, 80)}`,
});
return reply.send({ success: true, data: result });
} catch (err: unknown) {
return reply.status(500).send({
success: false,
error:
err instanceof Error
? err.message
: "Failed to execute container command",
});
}
},
);
app.put(
"/:id/file",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = ContainerWriteFileSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const container = await getAccessibleContainer(id, req.organizationId);
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error: "File Manager only works when the container is running",
});
}
try {
const data = await ssh.writeContainerFile(
container.server,
container.dockerId || container.name,
body.data.path,
body.data.content,
);
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_FILE_WRITE",
category: "CONTAINER",
level: "INFO",
message: `Updated file \"${body.data.path}\" in container \"${container.name}\"`,
});
return reply.send({ success: true, data });
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
app.post(
"/:id/files/file",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = ContainerCreateFileSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const container = await getAccessibleContainer(id, req.organizationId);
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error: "File Manager only works when the container is running",
});
}
try {
const data = await ssh.createContainerFile(
container.server,
container.dockerId || container.name,
body.data.path,
body.data.content ?? "",
);
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_FILE_CREATE",
category: "CONTAINER",
level: "INFO",
message: `Created file \"${body.data.path}\" in container \"${container.name}\"`,
});
return reply.status(201).send({ success: true, data });
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
app.post(
"/:id/files/folder",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = ContainerCreateFolderSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const container = await getAccessibleContainer(id, req.organizationId);
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error: "File Manager only works when the container is running",
});
}
try {
const data = await ssh.createContainerDirectory(
container.server,
container.dockerId || container.name,
body.data.path,
);
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_FOLDER_CREATE",
category: "CONTAINER",
level: "INFO",
message: `Created folder \"${body.data.path}\" in container \"${container.name}\"`,
});
return reply.status(201).send({ success: true, data });
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
app.patch(
"/:id/files/rename",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = ContainerRenamePathSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const container = await getAccessibleContainer(id, req.organizationId);
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error: "File Manager only works when the container is running",
});
}
try {
const data = await ssh.renameContainerPath(
container.server,
container.dockerId || container.name,
body.data.path,
body.data.newPath,
);
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_PATH_RENAME",
category: "CONTAINER",
level: "INFO",
message: `Renamed \"${body.data.path}\" to \"${body.data.newPath}\" in container \"${container.name}\"`,
});
return reply.send({ success: true, data });
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
app.delete(
"/:id/files",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const query = ContainerPathSchema.safeParse(req.query ?? {});
if (!query.success) {
return reply
.status(400)
.send({ success: false, error: query.error.flatten() });
}
const container = await getAccessibleContainer(id, req.organizationId);
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error: "File Manager only works when the container is running",
});
}
try {
const data = await ssh.deleteContainerPath(
container.server,
container.dockerId || container.name,
query.data.path,
);
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_PATH_DELETE",
category: "CONTAINER",
level: "WARNING",
message: `Deleted \"${query.data.path}\" in container \"${container.name}\"`,
});
return reply.send({ success: true, data });
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
app.get(
"/:id/file/download",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const query = ContainerPathSchema.safeParse(req.query ?? {});
if (!query.success) {
return reply
.status(400)
.send({ success: false, error: query.error.flatten() });
}
const container = await getAccessibleContainer(id, req.organizationId);
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error: "File Manager only works when the container is running",
});
}
try {
const data = await ssh.downloadContainerFile(
container.server,
container.dockerId || container.name,
query.data.path,
);
return reply.send({ success: true, data });
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
app.post(
"/:id/file/upload",
{
preHandler: containerWriteAccess,
bodyLimit: CONTAINER_UPLOAD_BODY_LIMIT_BYTES,
},
async (req, reply) => {
const { id } = req.params as { id: string };
const body = ContainerUploadFileSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const container = await getAccessibleContainer(id, req.organizationId);
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
if (container.status !== "RUNNING") {
return reply.status(400).send({
success: false,
error: "File Manager only works when the container is running",
});
}
try {
const data = await ssh.uploadContainerFile(
container.server,
container.dockerId || container.name,
body.data.directoryPath,
body.data.fileName,
body.data.contentBase64,
);
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_FILE_UPLOAD",
category: "CONTAINER",
level: "INFO",
message: `Uploaded \"${body.data.fileName}\" to \"${body.data.directoryPath}\" in container \"${container.name}\"`,
});
return reply.status(201).send({ success: true, data });
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
// GET /containers/:id
app.get("/:id", { preHandler: containerReadAccess }, async (req, reply) => {
const { id } = req.params as { id: string };
const container = await prisma.container.findFirst({
where: {
id,
server: { organizationId: req.organizationId! },
},
include: { server: { select: { name: true, ip: true } } },
});
if (!container)
return reply
.status(404)
.send({ success: false, error: "Container not found" });
return reply.send({ success: true, data: container });
});
// GET /containers/:id/metrics - lightweight runtime metrics for polling.
app.get(
"/:id/metrics",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const container = await getAccessibleContainer(id, req.organizationId);
if (!container)
return reply
.status(404)
.send({ success: false, error: "Container not found" });
const runtimeId = container.dockerId || container.name;
try {
const data = await readContainerMetricsWithCache({
organizationId: req.organizationId,
serverId: container.serverId,
serverName: container.server.name,
containerId: container.id,
containerName: container.name,
containerStatus: container.status,
dockerId: container.dockerId,
runtimeId,
loadStats: () =>
measureDockerCommand(
(event) => recordDockerCommandTiming(app, event),
{
endpoint: "containers.metrics",
category: "stats",
serverId: container.serverId,
serverName: container.server.name,
containerId: container.id,
},
() => ssh.dockerStats(container.server, runtimeId),
),
});
return reply.send({ success: true, data });
} catch (err: unknown) {
return reply.status(500).send({
success: false,
error: err instanceof Error ? err.message : "Failed to load metrics",
});
}
},
);
// GET /containers/:id/details — logs, inspect, runtime stats and processes
app.get(
"/:id/details",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const { lines = "200" } = req.query as { lines?: string };
const container = await getAccessibleContainer(id, req.organizationId);
if (!container)
return reply
.status(404)
.send({ success: false, error: "Container not found" });
const runtimeId = container.dockerId || container.name;
try {
const [logsResult, inspectResult, statsResult, processesResult] =
await Promise.allSettled([
measureDockerCommand(
(event) => recordDockerCommandTiming(app, event),
{
endpoint: "containers.details",
category: "logs",
serverId: container.serverId,
serverName: container.server.name,
containerId: container.id,
},
() =>
ssh.dockerLogs(
container.server,
runtimeId,
parseInt(lines, 10) || 200,
),
),
measureDockerCommand(
(event) => recordDockerCommandTiming(app, event),
{
endpoint: "containers.details",
category: "inspect",
serverId: container.serverId,
serverName: container.server.name,
containerId: container.id,
},
() => ssh.dockerInspect(container.server, runtimeId),
),
measureDockerCommand(
(event) => recordDockerCommandTiming(app, event),
{
endpoint: "containers.details",
category: "stats",
serverId: container.serverId,
serverName: container.server.name,
containerId: container.id,
},
() => ssh.dockerStats(container.server, runtimeId),
),
measureDockerCommand(
(event) => recordDockerCommandTiming(app, event),
{
endpoint: "containers.details",
category: "top",
serverId: container.serverId,
serverName: container.server.name,
containerId: container.id,
},
() => ssh.dockerTop(container.server, runtimeId),
),
]);
const logs = logsResult.status === "fulfilled" ? logsResult.value : "";
const inspect =
inspectResult.status === "fulfilled" ? inspectResult.value : {};
const stats =
statsResult.status === "fulfilled"
? statsResult.value
: {
cpuPercent: 0,
memoryPercent: 0,
pids: 0,
memory: { raw: "" },
network: { raw: "" },
io: { raw: "" },
};
const processes =
processesResult.status === "fulfilled" ? processesResult.value : [];
return reply.send({
success: true,
data: {
container: {
id: container.id,
name: container.name,
image: container.image,
status: container.status,
dockerId: container.dockerId,
serverId: container.serverId,
},
server: {
id: container.server.id,
name: container.server.name,
ip: container.server.ip,
},
logs,
inspect,
stats,
processes,
},
});
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
// GET /containers/:id/inspect — fetch raw docker inspect payload
app.get(
"/:id/inspect",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const container = await getAccessibleContainer(id, req.organizationId);
if (!container)
return reply
.status(404)
.send({ success: false, error: "Container not found" });
const runtimeId = container.dockerId || container.name;
try {
const inspect = await measureDockerCommand(
(event) => recordDockerCommandTiming(app, event),
{
endpoint: "containers.inspect",
category: "inspect",
serverId: container.serverId,
serverName: container.server.name,
containerId: container.id,
},
() => ssh.dockerInspect(container.server, runtimeId),
);
return reply.send({ success: true, data: inspect });
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
// GET /containers/:id/processes - fetch only the runtime process table.
app.get(
"/:id/processes",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const container = await getAccessibleContainer(id, req.organizationId);
if (!container)
return reply
.status(404)
.send({ success: false, error: "Container not found" });
const runtimeId = container.dockerId || container.name;
try {
const processes = await measureDockerCommand(
(event) => recordDockerCommandTiming(app, event),
{
endpoint: "containers.processes",
category: "top",
serverId: container.serverId,
serverName: container.server.name,
containerId: container.id,
},
() => ssh.dockerTop(container.server, runtimeId),
);
return reply.send({ success: true, data: processes });
} catch (err: unknown) {
return reply.status(500).send({
success: false,
error:
err instanceof Error ? err.message : "Failed to load processes",
});
}
},
);
// POST /containers - deploy a new container
app.post(
"/",
{ preHandler: containerWriteAccess, bodyLimit: 1024 * 1024 },
async (req, reply) => {
const body = DeploySchema.safeParse(req.body);
if (!body.success)
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
const validationError = validateDeployInput(body.data);
if (validationError) {
return reply
.status(400)
.send({ success: false, error: validationError });
}
const {
serverId,
environmentId,
ports,
env,
volumes,
sourceType,
deployMode,
accessToken,
gitProviderId,
buildPath,
startCommand,
portOverride,
publishDirectory,
networkId,
composeEnvFiles,
...rest
} = body.data;
const gitBuildType = resolveGitBuildType(body.data);
const server = await prisma.server.findFirst({
where: {
id: serverId,
organizationId: req.organizationId!,
},
});
if (!server)
return reply
.status(404)
.send({ success: false, error: "Server not found" });
const selectedEnvironmentId = toOptionalValue(environmentId);
if (selectedEnvironmentId) {
const selectedEnvironment = await prisma.project.findFirst({
where: {
organizationId: req.organizationId!,
environments: {
some: {
id: selectedEnvironmentId,
serverId,
},
},
},
select: { id: true },
});
if (!selectedEnvironment) {
return reply.status(404).send({
success: false,
error:
"Selected environment was not found for this server and organization",
});
}
}
const selectedNetworkId = toOptionalValue(networkId);
let selectedNetworkName: string | undefined;
if (selectedNetworkId) {
const network = await prisma.network.findFirst({
where: {
id: selectedNetworkId,
serverId,
server: { organizationId: req.organizationId! },
},
select: { name: true },
});
if (!network) {
return reply
.status(404)
.send({ success: false, error: "Selected network not found" });
}
selectedNetworkName = network.name;
}
let gitProvider: { id: string; name: string } | null = null;
if (sourceType === "GIT_PROVIDER") {
gitProvider = await prisma.userGitProvider.findFirst({
where: {
id: gitProviderId,
organizationId: req.organizationId!,
userId: req.userId!,
},
select: { id: true, name: true },
});
if (!gitProvider) {
return reply
.status(404)
.send({ success: false, error: "Git provider not found" });
}
}
const shouldValidateHostPorts =
collectRequestedHostPorts(body.data).length > 0;
const shouldValidateContainerName = shouldValidateRequestedContainerName(
body.data,
);
if (shouldValidateHostPorts || shouldValidateContainerName) {
try {
await syncContainersForServers([server], req.userId, (event) =>
recordDockerCommandTiming(app, event),
);
if (shouldValidateContainerName) {
await assertRequestedContainerNameAvailable({
deployInput: body.data,
serverId,
});
}
if (shouldValidateHostPorts) {
await assertRequestedHostPortsAvailable({
deployInput: body.data,
serverId,
});
}
} catch (error) {
const errorMessage = redactSecret(
error instanceof Error
? error.message
: "Failed to validate requested deployment target",
accessToken,
);
return reply.status(400).send({
success: false,
error: errorMessage,
});
}
}
if (sourceType === "MANUAL" && deployMode === "IMAGE") {
const image = toOptionalValue(rest.image)!;
// Save to DB first as STARTING
const container = await prisma.container.create({
data: {
name: rest.name,
image,
serverId,
environmentId: selectedEnvironmentId ?? null,
status: "STARTING",
sourceType: "MANUAL",
deployMode: "IMAGE",
restartPolicy: rest.restartPolicy,
ports: JSON.parse(JSON.stringify(parseDockerPorts(ports))),
envVars: JSON.parse(JSON.stringify(parseEnvironmentVariables(env))),
volumes: JSON.parse(
JSON.stringify(
(volumes || "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
),
),
},
});
if (req.headers["x-process-job-sync"] === "true") {
try {
const dockerId = await ssh.runContainer(server, {
name: rest.name,
image,
ports,
env,
restartPolicy: rest.restartPolicy,
volumes,
network: selectedNetworkName,
});
const updatedContainer = await prisma.container.update({
where: { id: container.id },
data: {
status: "RUNNING",
dockerId: dockerId.trim().slice(0, 12),
},
include: { server: { select: { name: true, ip: true } } },
});
await recordDeployment({
container: updatedContainer,
body: body.data,
trigger: "MANUAL",
userId: req.userId,
organizationId: req.organizationId!,
});
await auditLog({
userId: req.userId,
serverId,
action: "CONTAINER_DEPLOY",
category: "CONTAINER",
level: "SUCCESS",
message: `Container "${rest.name}" deployed on ${server.name}`,
});
return reply.send({
success: true,
data: updatedContainer,
message: "Deployment completed successfully",
});
} catch (err) {
const errorMessage = sanitizeDeploymentError(err, {
fallback: "Container deployment failed",
secrets: [accessToken],
});
await prisma.container.update({
where: { id: container.id },
data: { status: "ERROR" },
});
await recordFailedDeployment({
container,
body: body.data,
trigger: "MANUAL",
userId: req.userId,
organizationId: req.organizationId!,
error: errorMessage,
}).catch(() => undefined);
await auditLog({
userId: req.userId,
serverId,
action: "CONTAINER_DEPLOY",
category: "CONTAINER",
level: "ERROR",
message: `Deploy "${rest.name}" failed: ${errorMessage}`,
});
return reply.status(400).send({
success: false,
error: errorMessage,
});
}
}
// Deploy via SSH asynchronously
ssh
.runContainer(server, {
name: rest.name,
image,
ports,
env,
restartPolicy: rest.restartPolicy,
volumes,
network: selectedNetworkName,
})
.then(async (dockerId) => {
await prisma.container.update({
where: { id: container.id },
data: {
status: "RUNNING",
dockerId: dockerId.trim().slice(0, 12),
},
});
await recordDeployment({
container: {
id: container.id,
serverId: container.serverId,
image: container.image,
},
body: body.data,
trigger: "MANUAL",
userId: req.userId,
organizationId: req.organizationId!,
});
await auditLog({
userId: req.userId,
serverId,
action: "CONTAINER_DEPLOY",
category: "CONTAINER",
level: "SUCCESS",
message: `Container "${rest.name}" deployed on ${server.name}`,
});
})
.catch(async (err) => {
const errorMessage = sanitizeDeploymentError(err, {
fallback: "Container deployment failed",
secrets: [accessToken],
});
await prisma.container.update({
where: { id: container.id },
data: { status: "ERROR" },
});
await recordFailedDeployment({
container,
body: body.data,
trigger: "MANUAL",
userId: req.userId,
organizationId: req.organizationId!,
error: errorMessage,
}).catch(() => undefined);
await auditLog({
userId: req.userId,
serverId,
action: "CONTAINER_DEPLOY",
category: "CONTAINER",
level: "ERROR",
message: `Deploy "${rest.name}" failed: ${errorMessage}`,
});
});
return reply.status(202).send({
success: true,
data: container,
message: "Deployment started",
});
}
try {
let deploymentPath: string | undefined;
if (sourceType === "MANUAL" && deployMode === "COMPOSE") {
const result = await ssh.deployComposeStackFromContent(server, {
projectName: rest.name,
composeContent: toOptionalValue(rest.composeContent)!,
});
deploymentPath = result.deploymentPath;
} else if (sourceType === "MANUAL" && deployMode === "DOCKERFILE") {
const result = await ssh.buildAndRunContainerFromDockerfileContent(
server,
{
containerName: rest.name,
dockerfileContent: toOptionalValue(rest.dockerfileContent)!,
imageTag: toOptionalValue(rest.imageTag),
ports,
env,
restartPolicy: rest.restartPolicy,
volumes,
network: selectedNetworkName,
},
);
deploymentPath = result.deploymentPath;
} else {
const result = await ssh.deployContainerFromGitSource(server, {
projectName: rest.name,
repoUrl: toOptionalValue(rest.repoUrl)!,
branch: toOptionalValue(rest.repoBranch),
accessToken: toOptionalValue(accessToken),
buildType: gitBuildType as
| "NIXPACKS"
| "HEROKU_BUILDPACKS"
| "PAKETO_BUILDPACKS"
| "STATIC"
| "DOCKERFILE"
| "COMPOSE",
buildPath: toOptionalValue(buildPath),
composeFilePath: toOptionalValue(rest.composeFilePath),
composeEnvFiles,
dockerfilePath: toOptionalValue(rest.dockerfilePath),
dockerContextPath: toOptionalValue(rest.dockerContextPath),
imageTag: toOptionalValue(rest.imageTag),
containerName: rest.name,
ports,
portOverride: toOptionalValue(portOverride),
env,
startCommand: toOptionalValue(startCommand),
publishDirectory: toOptionalValue(publishDirectory),
restartPolicy: rest.restartPolicy,
volumes,
network: selectedNetworkName,
});
deploymentPath = result.deploymentPath;
}
await syncContainersForServers([server], req.userId, (event) =>
recordDockerCommandTiming(app, event),
);
const serverContainers = await prisma.container.findMany({
where: { serverId: server.id },
orderBy: { createdAt: "desc" },
});
const matchedContainers = serverContainers.filter((container) =>
matchesDeploymentTarget(
container.name,
rest.name,
gitBuildType === "COMPOSE" ? "COMPOSE" : deployMode,
),
);
if (matchedContainers.length === 0) {
throw new Error(
gitBuildType === "COMPOSE"
? "Deployment finished but no compose containers could be matched after sync"
: "Deployment finished but the container was not found after sync",
);
}
await persistDeploymentSourceMetadata(
matchedContainers.map((container) => ({ id: container.id })),
body.data,
deploymentPath,
gitProvider?.id,
);
if (selectedEnvironmentId) {
await prisma.container.updateMany({
where: {
id: { in: matchedContainers.map((container) => container.id) },
},
data: { environmentId: selectedEnvironmentId },
});
}
const responseContainers = await prisma.container.findMany({
where: {
id: { in: matchedContainers.map((container) => container.id) },
},
orderBy: { createdAt: "desc" },
include: { server: { select: { name: true, ip: true } } },
});
await Promise.all(
responseContainers.map((matchedContainer) =>
recordDeployment({
container: matchedContainer,
body: body.data,
trigger:
body.data.sourceType === "APP_INSTALLER"
? "APP_INSTALLER"
: "MANUAL",
userId: req.userId,
organizationId: req.organizationId!,
}),
),
).catch((error) => {
app.log.error({ error }, "Failed to persist deployment history");
});
await auditLog({
userId: req.userId,
serverId,
action: "CONTAINER_DEPLOY",
category: "CONTAINER",
level: "SUCCESS",
message:
gitBuildType === "COMPOSE"
? `Compose deployment "${rest.name}" completed on ${server.name}`
: `Container deployment "${rest.name}" completed on ${server.name}`,
});
return reply.send({
success: true,
data: responseContainers[0] ?? null,
meta: {
matchedContainers: responseContainers,
matchedCount: responseContainers.length,
},
message:
gitBuildType === "COMPOSE"
? `Compose deployment completed and matched ${responseContainers.length} container(s)`
: "Deployment completed successfully",
});
} catch (error) {
const errorMessage = sanitizeDeploymentError(
ssh.formatDeploymentErrorMessage(error),
{
fallback: "Container deployment failed",
secrets: [accessToken],
},
);
const failedContainer = await prisma.container.findFirst({
where: {
serverId,
name: { equals: rest.name, mode: "insensitive" },
},
select: { id: true, serverId: true, image: true },
});
if (failedContainer) {
await recordFailedDeployment({
container: failedContainer,
body: body.data,
trigger: body.data.sourceType === "APP_INSTALLER" ? "APP_INSTALLER" : "MANUAL",
userId: req.userId,
organizationId: req.organizationId!,
error: errorMessage,
}).catch(() => undefined);
}
await auditLog({
userId: req.userId,
serverId,
action: "CONTAINER_DEPLOY",
category: "CONTAINER",
level: "ERROR",
message: `Deploy "${rest.name}" failed: ${errorMessage}`,
});
return reply.status(400).send({
success: false,
error: errorMessage,
});
}
},
);
// POST /containers/:id/action — start | stop | restart | rm
app.post(
"/:id/action",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = z
.object({
action: z.enum([
"start",
"stop",
"restart",
"rm",
"pause",
"unpause",
]),
})
.safeParse(req.body);
if (!body.success)
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
const container = await getAccessibleContainer(id, req.organizationId);
if (!container)
return reply
.status(404)
.send({ success: false, error: "Container not found" });
const { action } = body.data;
try {
// Prefer dockerId (works even if container got renamed).
// Fallback to name if dockerId is stale (container recreated).
const candidates = [container.dockerId, container.name].filter(
(value): value is string => !!value && value.trim().length > 0,
);
let runtimeRefUsed: string | null = null;
let lastError: unknown = null;
for (const candidate of candidates) {
try {
await ssh.dockerAction(container.server, candidate, action);
runtimeRefUsed = candidate;
lastError = null;
break;
} catch (error) {
lastError = error;
if (isContainerNotFoundError(error)) {
continue;
}
throw error;
}
}
if (!runtimeRefUsed) {
throw lastError instanceof Error
? lastError
: new Error("Container not found in Docker runtime");
}
let updatedContainer: any = null;
if (action === "rm") {
await deleteRelatedAppInstalls(container.serverId, container.name);
await prisma.container.delete({ where: { id } });
} else {
// Read actual runtime status after action so DB reflects reality.
let inspect: any = null;
try {
inspect = await measureDockerCommand(
(event) => recordDockerCommandTiming(app, event),
{
endpoint: "containers.action",
category: "inspect",
serverId: container.serverId,
serverName: container.server.name,
containerId: container.id,
},
() => ssh.dockerInspect(container.server, runtimeRefUsed),
);
} catch {
inspect = null;
}
const actualStatus = mapDockerInspectStatus(inspect?.State?.Status);
const dockerIdFromInspect =
typeof inspect?.Id === "string" && inspect.Id.trim()
? inspect.Id.trim().slice(0, 12)
: container.dockerId;
updatedContainer = await prisma.container.update({
where: { id },
data: {
status: actualStatus as any,
dockerId: dockerIdFromInspect ?? undefined,
},
include: { server: { select: { name: true, ip: true } } },
});
if (action === "start" || action === "restart") {
await syncContainersForServers(
[container.server],
req.userId,
(event) => recordDockerCommandTiming(app, event),
);
updatedContainer =
(await prisma.container.findFirst({
where: {
id,
server: { organizationId: req.organizationId! },
},
include: { server: { select: { name: true, ip: true } } },
})) ?? updatedContainer;
}
}
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: `CONTAINER_${action.toUpperCase()}`,
category: "CONTAINER",
level: "INFO",
message: `Container "${container.name}" → ${action}`,
});
invalidateContainerMetricsCache({
organizationId: req.organizationId,
serverId: container.serverId,
containerId: container.id,
});
return reply.send({
success: true,
data: updatedContainer ?? undefined,
message: `Container ${action}ed`,
});
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
app.post(
"/:id/rebuild-job",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const container = await prisma.container.findFirst({
where: {
id,
server: { organizationId: req.organizationId! },
},
select: { id: true },
});
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
const job = await createProcessJob({
type: "container_rebuild",
userId: req.userId,
organizationId: req.organizationId,
});
runInjectedContainerJob({
app,
job,
method: "POST",
url: req.url.replace(/\/rebuild-job(?:\?.*)?$/, "/rebuild"),
headers: req.headers,
payload: {},
});
return reply.status(202).send({
success: true,
data: serializeProcessJob(job),
});
},
);
app.post(
"/:id/rebuild",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const container = await prisma.container.findFirst({
where: {
id,
server: { organizationId: req.organizationId! },
},
include: {
server: true,
deploymentSource: true,
},
});
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
let gitDeploymentId: string | null = null;
let gitDeploymentLock: { token: string } | null = null;
let gitDeploymentHeartbeat: ReturnType<
typeof startDeploymentLockHeartbeat
> | null = null;
let gitAccessToken: string | undefined;
try {
await reconcileInterruptedRebuild({
container,
inspectRuntime: ssh.dockerInspect,
});
if (container.sourceType === "APP_INSTALLER") {
const install = await prisma.appInstall.findFirst({
where: {
serverId: container.serverId,
containerName: { equals: container.name, mode: "insensitive" },
},
});
if (!install) {
return reply.status(400).send({
success: false,
error:
"No App Installer deployment metadata was found for this container",
});
}
const result = await rebuildAppInstall({
installId: install.id,
userId: req.userId,
organizationId: req.organizationId,
});
const updatedContainer = await prisma.container.findFirst({
where: {
id: container.id,
server: { organizationId: req.organizationId! },
},
include: { server: { select: { name: true, ip: true } } },
});
return reply.send({
success: true,
data: updatedContainer ?? undefined,
message: result.pulledLatestImage
? "Container rebuilt successfully with the latest image"
: "Container rebuilt successfully using the cached image",
});
}
if (!isGitRedeploySourceType(container.sourceType)) {
return reply.status(400).send({
success: false,
error:
"Rebuild is only available for App Installer and Git/Repo containers",
});
}
if (!container.deploymentSource?.repoUrl) {
return reply.status(400).send({
success: false,
error:
"Git deployment metadata is incomplete for this container. Redeploy from Git cannot continue.",
});
}
const dockerStatus = await ssh.getDockerRuntimeStatus(container.server);
if (!dockerStatus.available) {
return reply.status(400).send({
success: false,
error:
dockerStatus.reason ||
"Docker is not available on the selected server",
});
}
const buildType = resolveStoredGitBuildType(
container.deploymentSource.buildType,
container.deployMode,
);
const runtimeConfig = await resolveContainerRuntimeConfig(container);
const deploymentProjectName = resolveDeploymentProjectName({
containerName: container.name,
source: container.deploymentSource,
});
const composeEnvFiles = parseStoredComposeEnvOverrides(
container.deploymentSource.composeEnvOverrides,
);
const accessToken = container.deploymentSource.accessTokenEnc
? decrypt(container.deploymentSource.accessTokenEnc)
: undefined;
gitAccessToken = accessToken;
const gitDeploymentSnapshot = {
sourceType: container.sourceType,
deployMode: container.deployMode,
buildType,
image: container.image,
ports: runtimeConfig.ports,
env: runtimeConfig.env,
volumes: runtimeConfig.volumes,
network: runtimeConfig.network,
restartPolicy: runtimeConfig.restartPolicy,
repoUrl: container.deploymentSource.repoUrl,
repoBranch: container.deploymentSource.repoBranch,
buildPath: container.deploymentSource.buildPath,
composeFilePath: container.deploymentSource.composeFilePath,
dockerfilePath: container.deploymentSource.dockerfilePath,
dockerContextPath:
container.deploymentSource.dockerContextPath,
imageTag: container.deploymentSource.imageTag,
deploymentPath: container.deploymentSource.deploymentPath,
};
gitDeploymentLock = await acquireDeploymentLock({
containerId: container.id,
});
gitDeploymentHeartbeat = startDeploymentLockHeartbeat({
containerId: container.id,
token: gitDeploymentLock.token,
});
const runningDeployment = await createDeployment({
containerId: container.id,
organizationId: req.organizationId!,
serverId: container.serverId,
userId: req.userId,
status: "RUNNING",
trigger: "REBUILD",
version:
container.deploymentSource.repoBranch?.trim() || container.image,
branch: container.deploymentSource.repoBranch?.trim() || null,
image: container.image,
configSnapshot: gitDeploymentSnapshot,
startedAt: new Date(),
});
gitDeploymentId = runningDeployment.id;
if (buildType !== "COMPOSE") {
for (const candidate of [container.dockerId, container.name]) {
if (!candidate?.trim()) continue;
try {
await ssh.dockerAction(container.server, candidate, "stop");
} catch (error) {
if (!isContainerNotFoundError(error)) {
throw error;
}
}
try {
await ssh.dockerAction(container.server, candidate, "rm");
break;
} catch (error) {
if (!isContainerNotFoundError(error)) {
throw error;
}
}
}
}
const gitDeploymentResult = await ssh.deployContainerFromGitSource(
container.server,
{
projectName: deploymentProjectName,
repoUrl: container.deploymentSource.repoUrl,
branch: toOptionalValue(container.deploymentSource.repoBranch),
accessToken,
buildType,
buildPath: toOptionalValue(container.deploymentSource.buildPath),
composeFilePath: toOptionalValue(
container.deploymentSource.composeFilePath,
),
composeEnvFiles,
dockerfilePath: toOptionalValue(
container.deploymentSource.dockerfilePath,
),
dockerContextPath: toOptionalValue(
container.deploymentSource.dockerContextPath,
),
imageTag: toOptionalValue(container.deploymentSource.imageTag),
containerName: container.name,
ports: runtimeConfig.ports,
portOverride: toOptionalValue(
container.deploymentSource.portOverride,
),
env: runtimeConfig.env,
startCommand: toOptionalValue(
container.deploymentSource.startCommand,
),
publishDirectory: toOptionalValue(
container.deploymentSource.publishDirectory,
),
restartPolicy: runtimeConfig.restartPolicy,
volumes: runtimeConfig.volumes,
network: runtimeConfig.network,
deploymentPath: toOptionalValue(
container.deploymentSource.deploymentPath,
),
},
);
gitDeploymentHeartbeat.assertOwned();
await syncContainersForServers(
[container.server],
req.userId,
(event) => recordDockerCommandTiming(app, event),
);
const serverContainers = await prisma.container.findMany({
where: { serverId: container.serverId },
orderBy: { createdAt: "desc" },
});
const matchedContainers = serverContainers.filter((candidate) =>
matchesDeploymentTarget(
candidate.name,
buildType === "COMPOSE" ? deploymentProjectName : container.name,
buildType === "COMPOSE" ? "COMPOSE" : container.deployMode,
),
);
if (matchedContainers.length === 0) {
throw new Error(
buildType === "COMPOSE"
? "Redeploy from Git finished but no compose containers could be matched after sync"
: "Redeploy from Git finished but the container was not found after sync",
);
}
await persistExistingDeploymentSourceMetadata(
matchedContainers.map((candidate) => ({ id: candidate.id })),
container.sourceType,
buildType === "COMPOSE"
? "COMPOSE"
: buildType === "DOCKERFILE"
? "DOCKERFILE"
: container.deployMode,
container.deploymentSource,
);
const responseContainers = await prisma.container.findMany({
where: {
id: { in: matchedContainers.map((candidate) => candidate.id) },
server: { organizationId: req.organizationId! },
},
orderBy: { createdAt: "desc" },
include: { server: { select: { name: true, ip: true } } },
});
const updatedContainer =
responseContainers.find(
(candidate) =>
candidate.name.toLowerCase() === container.name.toLowerCase(),
) ?? responseContainers[0];
if (!updatedContainer) {
throw new Error(
"Redeploy from Git finished but no primary container could be resolved",
);
}
gitDeploymentHeartbeat.assertOwned();
let imageDigest: string | null = null;
try {
const inspect = (await ssh.dockerInspect(
container.server,
updatedContainer.dockerId || updatedContainer.name,
)) as DockerInspectRuntime;
imageDigest =
typeof inspect.Image === "string" && inspect.Image.trim()
? inspect.Image.trim()
: null;
} catch {
// The canonical runtime image is still persisted even when Docker
// does not return an image ID after the successful health gate.
}
await updateDeployment(runningDeployment.id, {
status: "SUCCESS",
completedAt: new Date(),
image: updatedContainer.image,
imageDigest,
commitSha: gitDeploymentResult.commitSha,
branch: container.deploymentSource.repoBranch?.trim() || null,
configSnapshot: {
...gitDeploymentSnapshot,
image: updatedContainer.image,
imageDigest,
commitSha: gitDeploymentResult.commitSha,
matchedContainerIds: responseContainers.map(
(candidate) => candidate.id,
),
},
});
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_REDEPLOY_GIT",
category: "CONTAINER",
level: "SUCCESS",
message: `Container "${container.name}" redeployed from Git on ${container.server.name}`,
meta: {
deploymentId: runningDeployment.id,
commitSha: gitDeploymentResult.commitSha,
branch: container.deploymentSource.repoBranch,
image: updatedContainer.image,
imageDigest,
matchedContainerIds: responseContainers.map(
(candidate) => candidate.id,
),
},
});
return reply.send({
success: true,
data: updatedContainer ?? undefined,
message:
buildType === "COMPOSE"
? `Redeploy from Git completed and matched ${responseContainers.length} container(s)`
: "Container redeployed successfully from Git",
});
} catch (err: unknown) {
const errorMessage = gitDeploymentId
? sanitizeDeploymentError(ssh.formatDeploymentErrorMessage(err), {
fallback: "Git redeploy failed",
secrets: [gitAccessToken],
})
: err instanceof Error
? err.message
: "Failed to rebuild container";
if (gitDeploymentId) {
await updateDeployment(gitDeploymentId, {
status: "FAILED",
error: errorMessage,
completedAt: new Date(),
}).catch(() => undefined);
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
serverId: container.serverId,
action: "CONTAINER_REDEPLOY_GIT",
category: "CONTAINER",
level: "ERROR",
message: `Git redeploy failed for container "${container.name}": ${errorMessage}`,
meta: { deploymentId: gitDeploymentId },
}).catch(() => undefined);
}
return reply.status(500).send({
success: false,
error: errorMessage,
});
} finally {
gitDeploymentHeartbeat?.stop();
if (gitDeploymentLock) {
await releaseDeploymentLock({
containerId: container.id,
token: gitDeploymentLock.token,
}).catch(() => undefined);
}
}
},
);
// GET /containers/:id/logs — fetch container logs via SSH
app.get(
"/:id/logs",
{ preHandler: containerReadAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const { lines = "200" } = req.query as { lines?: string };
const container = await getAccessibleContainer(id, req.organizationId);
if (!container)
return reply
.status(404)
.send({ success: false, error: "Container not found" });
if (!container.dockerId)
return reply
.status(400)
.send({ success: false, error: "Container has no Docker ID" });
const runtimeId = container.dockerId;
try {
const logs = await measureDockerCommand(
(event) => recordDockerCommandTiming(app, event),
{
endpoint: "containers.logs",
category: "logs",
serverId: container.serverId,
serverName: container.server.name,
containerId: container.id,
},
() => ssh.dockerLogs(container.server, runtimeId, parseInt(lines)),
);
return reply.send({
success: true,
data: { logs, containerName: container.name },
});
} catch (err: any) {
return reply.status(500).send({ success: false, error: err.message });
}
},
);
// DELETE /containers/:id
app.delete(
"/:id",
{ preHandler: containerWriteAccess },
async (req, reply) => {
const { id } = req.params as { id: string };
const container = await getAccessibleContainer(id, req.organizationId);
if (!container)
return reply
.status(404)
.send({ success: false, error: "Container not found" });
if (container.dockerId) {
try {
await ssh.dockerAction(container.server, container.dockerId, "rm");
} catch {
/* ignore if already gone */
}
}
await deleteRelatedAppInstalls(container.serverId, container.name);
await prisma.container.delete({ where: { id } });
await auditLog({
userId: req.userId,
serverId: container.serverId,
action: "CONTAINER_DELETE",
category: "CONTAINER",
level: "WARNING",
message: `Container "${container.name}" removed`,
});
return reply.send({ success: true, message: "Container deleted" });
},
);
}