feat(deployment): add deployment history and rollback support

This commit is contained in:
KodekaTeam committed 2026-07-17 00:27:07 +07:00
1 parent 029b25b596
commit 4e9b173edb
36 files changed
+3663 -569

No files matched your search

+9 -8
View File
@@ -5,6 +5,7 @@ import jwt from "@fastify/jwt";
import rateLimit from "@fastify/rate-limit";
import websocket from "@fastify/websocket";
import prisma from "./lib/prisma";
import { validateEncryptionConfiguration } from "./lib/crypto";
import { authRoutes } from "./routes/auth";
import { serverRoutes } from "./routes/servers";
@@ -109,10 +110,11 @@ async function ensureDatabaseConnection() {
async function start() {
const jwtSecret = getJwtSecretOrThrow();
validateEncryptionConfiguration();
await ensureDatabaseConnection();
setDefaultSecurityHeaders();
// ── Plugins ──────────────────────────────────────────
// ── Plugins ──────────────────────────────────────────
await app.register(cors, {
origin: getCorsOrigins(),
credentials: true,
@@ -143,7 +145,7 @@ async function start() {
await app.register(websocket);
// ── Health check ──────────────────────────────────────
// ── Health check ──────────────────────────────────────
app.get("/health", { config: { rateLimit: false } }, async () => ({
status: "ok",
uptime: process.uptime(),
@@ -151,7 +153,7 @@ async function start() {
version: process.env.NEXT_PUBLIC_VERSION || "unknown",
}));
// ── API Routes ────────────────────────────────────────
// ── API Routes ────────────────────────────────────────
const API_PREFIX = "/api/v1";
await app.register(authRoutes, { prefix: `${API_PREFIX}/auth` });
@@ -183,7 +185,7 @@ async function start() {
prefix: `${API_PREFIX}/notifications`,
});
// ── Error handler ─────────────────────────────────────
// ── Error handler ─────────────────────────────────────
app.setErrorHandler((error: FastifyError, _req, reply) => {
app.log.error(error);
const code = error.statusCode || 500;
@@ -194,14 +196,14 @@ async function start() {
});
});
// ── Start ─────────────────────────────────────────────
// ── Start ─────────────────────────────────────────────
try {
await app.listen({ port: PORT, host: HOST });
startS3StorageRetentionScheduler();
console.log(
`\n🚀 Doktainer Server Backend running on http://${HOST}:${PORT}`,
`\n🚀 Doktainer Server Backend running on http://${HOST}:${PORT}`,
);
console.log(`📋 Health Check: http://localhost:${PORT}/health\n`);
console.log(`📋 Health Check: http://localhost:${PORT}/health\n`);
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === "EADDRINUSE") {
@@ -227,4 +229,3 @@ if (require.main === module) {
}
export { app, start };
+62 -15
View File
@@ -1,11 +1,56 @@
import crypto from 'crypto';
import crypto from "crypto";
const ALGORITHM = 'aes-256-gcm';
const ALGORITHM = "aes-256-gcm";
export const DEVELOPMENT_ENCRYPTION_KEY =
"dev-32-char-encryption-key-here!";
const REJECTED_PRODUCTION_KEYS = new Set([
DEVELOPMENT_ENCRYPTION_KEY,
"change-me",
"changeme",
"replace-me",
"replace-with-a-secure-random-key",
]);
export function getEncryptionKeyOrThrow(env = process.env): string {
const configuredKey = env.ENCRYPTION_KEY?.trim();
const isProduction = env.NODE_ENV === "production";
if (!configuredKey) {
if (isProduction) {
throw new Error(
"ENCRYPTION_KEY must be configured before starting Doktainer in production",
);
}
return DEVELOPMENT_ENCRYPTION_KEY;
}
if (configuredKey.length < 32) {
throw new Error("ENCRYPTION_KEY must be at least 32 characters long");
}
if (
isProduction &&
(REJECTED_PRODUCTION_KEYS.has(configuredKey.toLowerCase()) ||
/^dev[-_]/i.test(configuredKey) ||
new Set(configuredKey).size < 8 ||
/^(.)\1+$/.test(configuredKey))
) {
throw new Error(
"ENCRYPTION_KEY must use a strong random value and must not use a development or placeholder value in production",
);
}
return configuredKey;
}
export function validateEncryptionConfiguration(env = process.env): void {
getEncryptionKeyOrThrow(env);
}
function getKey(): Buffer {
const key = process.env.ENCRYPTION_KEY || 'dev-32-char-encryption-key-here!';
const key = getEncryptionKeyOrThrow();
// Derive a 32-byte key using SHA-256
return crypto.createHash('sha256').update(key).digest();
return crypto.createHash("sha256").update(key).digest();
}
/**
@@ -17,31 +62,33 @@ export function encrypt(plaintext: string): string {
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return [
iv.toString('base64'),
authTag.toString('base64'),
encrypted.toString('base64'),
].join(':');
iv.toString("base64"),
authTag.toString("base64"),
encrypted.toString("base64"),
].join(":");
}
/**
* Decrypt an encrypted string produced by `encrypt()`
*/
export function decrypt(encoded: string): string {
const [ivB64, tagB64, dataB64] = encoded.split(':');
if (!ivB64 || !tagB64 || !dataB64) throw new Error('Invalid encrypted format');
const [ivB64, tagB64, dataB64] = encoded.split(":");
if (!ivB64 || !tagB64 || !dataB64) throw new Error("Invalid encrypted format");
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(tagB64, 'base64');
const data = Buffer.from(dataB64, 'base64');
const iv = Buffer.from(ivB64, "base64");
const authTag = Buffer.from(tagB64, "base64");
const data = Buffer.from(dataB64, "base64");
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
return Buffer.concat([decipher.update(data), decipher.final()]).toString(
"utf8",
);
}
+143 -58
View File
@@ -7,6 +7,11 @@ import { auditLog } from "../services/audit.service";
import { dispatchRuntimeNotification } from "../services/notification.service";
import * as ssh from "../services/ssh.service";
import { rebuildAppInstall } from "../services/app-install-rebuild.service";
import {
createDeployment,
updateDeployment,
} from "../services/deployment.service";
import { sanitizeDeploymentError } from "../services/deployment-error.service";
import {
APP_TEMPLATES,
AppTemplate,
@@ -98,48 +103,6 @@ type DockerInspectRuntime = {
}>;
};
type DockerPortBindings = Record<
string,
Array<{ HostIp?: string; HostPort?: string }> | null
>;
function formatEnvLines(env?: string[] | null): string {
return (env ?? []).filter(Boolean).join("\n");
}
function formatMountBindings(mounts?: DockerInspectRuntime["Mounts"]): string {
return (mounts ?? [])
.map((mount) => {
if (!mount.Source || !mount.Destination) return "";
return `${mount.Source}:${mount.Destination}${mount.RW === false ? ":ro" : ""}`;
})
.filter(Boolean)
.join(",");
}
function formatPortBindings(bindings?: DockerPortBindings): string {
if (!bindings) return "";
return Object.entries(bindings)
.flatMap(([containerPortSpec, hostBindings]) => {
if (!hostBindings || hostBindings.length === 0) return [];
const [containerPort, protocol = "tcp"] = containerPortSpec.split("/");
return hostBindings
.map((binding) => {
const hostPort = binding.HostPort?.trim();
if (!hostPort) return "";
return `${hostPort}:${containerPort}${protocol !== "tcp" ? `/${protocol}` : ""}`;
})
.filter(Boolean);
})
.join(",");
}
function formatCommandParts(cmd?: string[] | null): string {
return (cmd ?? []).filter(Boolean).join(" ").trim();
}
function parseCsvBindings(value: string): string[] {
return value
.split(",")
@@ -1045,16 +1008,88 @@ export async function appsRoutes(app: FastifyInstance) {
});
}
const install = await prisma.appInstall.create({
data: {
appId: finalAppId,
appName: finalAppName,
serverId: server.id,
containerName,
port: finalPorts || null,
status: "INSTALLING",
const { install, provisionalContainer } = await prisma.$transaction(
async (tx) => {
const createdInstall = await tx.appInstall.create({
data: {
appId: finalAppId,
appName: finalAppName,
serverId: server.id,
containerName,
port: finalPorts || null,
status: "INSTALLING",
},
});
const createdContainer = await tx.container.create({
data: {
name: containerName,
image: finalImage,
serverId: server.id,
environmentId: selectedEnvironmentId,
status: "STARTING",
sourceType: "APP_INSTALLER",
deployMode: "IMAGE",
ports: JSON.parse(JSON.stringify(parseCsvBindings(finalPorts))),
envVars: JSON.parse(
JSON.stringify(parseEnvironmentVariables(finalEnv)),
),
volumes: JSON.parse(
JSON.stringify(parseCsvBindings(finalVolumes)),
),
restartPolicy: finalRestartPolicy,
},
});
return {
install: createdInstall,
provisionalContainer: createdContainer,
};
},
});
);
const deploymentStartedAt = new Date();
let deployment;
try {
deployment = await createDeployment({
containerId: provisionalContainer.id,
organizationId: req.organizationId!,
serverId: server.id,
userId: req.userId,
status: "RUNNING",
trigger: "APP_INSTALLER",
version: finalImage,
image: finalImage,
configSnapshot: {
sourceType: "APP_INSTALLER",
deployMode: "IMAGE",
image: finalImage,
ports: finalPorts,
env: finalEnv,
volumes: finalVolumes,
network: finalNetwork,
restartPolicy: finalRestartPolicy,
command: finalCommand,
appId: finalAppId,
appName: finalAppName,
presetId: body.data.presetId ?? null,
},
startedAt: deploymentStartedAt,
});
} catch (error) {
const message = sanitizeDeploymentError(error, {
fallback: "Failed to create deployment history",
});
await prisma.$transaction([
prisma.container.update({
where: { id: provisionalContainer.id },
data: { status: "ERROR" },
}),
prisma.appInstall.update({
where: { id: install.id },
data: { status: "FAILED", error: message },
}),
]);
return reply.status(500).send({ success: false, error: message });
}
setImmediate(async () => {
try {
@@ -1069,6 +1104,13 @@ export async function appsRoutes(app: FastifyInstance) {
command: finalCommand,
mountValidation: resolveAppMountValidation(finalAppId),
});
await prisma.container.update({
where: { id: provisionalContainer.id },
data: {
dockerId: dockerId.trim().slice(0, 12) || null,
status: "STARTING",
},
});
await upsertAppInstallerContainer({
serverId: server.id,
@@ -1082,6 +1124,32 @@ export async function appsRoutes(app: FastifyInstance) {
restartPolicy: finalRestartPolicy,
});
const deployedContainer = await prisma.container.findUnique({
where: { id: provisionalContainer.id },
select: { id: true, image: true },
});
if (deployedContainer) {
await updateDeployment(deployment.id, {
status: "SUCCESS",
image: deployedContainer.image || finalImage,
configSnapshot: {
sourceType: "APP_INSTALLER",
deployMode: "IMAGE",
image: deployedContainer.image || finalImage,
ports: finalPorts,
env: finalEnv,
volumes: finalVolumes,
network: finalNetwork,
restartPolicy: finalRestartPolicy,
command: finalCommand,
appId: finalAppId,
appName: finalAppName,
presetId: body.data.presetId ?? null,
},
completedAt: new Date(),
});
}
await prisma.appInstall.update({
where: { id: install.id },
data: { status: "RUNNING" },
@@ -1125,11 +1193,28 @@ export async function appsRoutes(app: FastifyInstance) {
},
});
}
} catch (err: any) {
await prisma.appInstall.update({
where: { id: install.id },
data: { status: "FAILED", error: err.message },
} catch (err: unknown) {
const errorMessage = sanitizeDeploymentError(err, {
fallback: "App installation failed",
});
await prisma.$transaction([
prisma.appInstall.update({
where: { id: install.id },
data: { status: "FAILED", error: errorMessage },
}),
prisma.container.update({
where: { id: provisionalContainer.id },
data: { status: "ERROR" },
}),
prisma.deployment.update({
where: { id: deployment.id },
data: {
status: "FAILED",
error: errorMessage,
completedAt: new Date(),
},
}),
]);
await auditLog({
userId: req.userId,
@@ -1138,7 +1223,7 @@ export async function appsRoutes(app: FastifyInstance) {
action: "APP_INSTALL_FAILED",
category: "SYSTEM",
level: "ERROR",
message: `App "${finalAppName}" install failed on "${server.name}": ${err.message}`,
message: `App "${finalAppName}" install failed on "${server.name}": ${errorMessage}`,
meta: {
mode: body.data.mode,
image: finalImage,
@@ -1151,7 +1236,7 @@ export async function appsRoutes(app: FastifyInstance) {
organizationId: req.organizationId,
action: "app_build_error",
title: `App deploy failed: ${finalAppName}`,
message: `App ${finalAppName} failed to deploy on ${server.name}. ${err.message}`,
message: `App ${finalAppName} failed to deploy on ${server.name}. ${errorMessage}`,
serverId: server.id,
resourceType: "app_install",
resourceId: install.id,
@@ -1164,7 +1249,7 @@ export async function appsRoutes(app: FastifyInstance) {
presetId: body.data.presetId ?? null,
serverId: server.id,
serverName: server.name,
error: err.message,
error: errorMessage,
},
});
}
+439 -42
View File
@@ -25,6 +25,19 @@ import {
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";
const DeploySourceTypeSchema = z.enum([
"APP_INSTALLER",
@@ -707,8 +720,7 @@ export function resolveContainerPathToHostMountPath(
const matchedMount = mounts
.filter(
(mount) =>
isUsableMountPath(mount.Source) &&
isUsableMountPath(mount.Destination),
isUsableMountPath(mount.Source) && isUsableMountPath(mount.Destination),
)
.map((mount) => ({
source: normalizeRemotePath(mount.Source!),
@@ -732,7 +744,10 @@ export function resolveContainerPathToHostMountPath(
return pathPosix.normalize(pathPosix.join(matchedMount.source, relativePath));
}
function getWritableMountRootsForPath(targetPath: string, mounts: DockerInspectMount[]) {
function getWritableMountRootsForPath(
targetPath: string,
mounts: DockerInspectMount[],
) {
const normalizedTargetPath = normalizeRemotePath(targetPath);
return mounts
@@ -944,6 +959,7 @@ function resolveGitBuildType(input: z.infer<typeof DeploySchema>) {
}
type DockerInspectRuntime = {
Image?: string;
Config?: {
Image?: string;
Env?: string[];
@@ -1564,7 +1580,7 @@ function parseInjectedResponsePayload(response: {
function runInjectedContainerJob(input: {
app: FastifyInstance;
job: ReturnType<typeof createProcessJob>;
job: Awaited<ReturnType<typeof createProcessJob>>;
method: "POST";
url: string;
headers: Record<string, unknown>;
@@ -1574,19 +1590,22 @@ function runInjectedContainerJob(input: {
void Promise.resolve().then(async () => {
const abortController = new AbortController();
if (isProcessJobCancelling(input.job)) {
updateProcessJob(input.job, {
await updateProcessJob(input.job, {
status: "cancelled",
error: input.job.cancelReason ?? "Job cancelled",
});
appendProcessJobLog(input.job, "[job] Cancelled");
await appendProcessJobLog(input.job, "[job] Cancelled");
return;
}
updateProcessJob(input.job, {
await updateProcessJob(input.job, {
status: "running",
cancel: (reason) => abortController.abort(reason),
});
appendProcessJobLog(input.job, `[job] Started ${input.job.type}`);
await appendProcessJobLog(
input.job,
`[job] Started ${input.job.type}`,
);
try {
const response = await withCommandLogSink(
@@ -1601,7 +1620,10 @@ function runInjectedContainerJob(input: {
await input.app.inject({
method: input.method,
url: input.url,
headers: buildInternalJobHeaders(input.headers),
headers: {
...buildInternalJobHeaders(input.headers),
"x-process-job-sync": "true",
},
payload: input.payload,
}),
{
@@ -1610,12 +1632,12 @@ function runInjectedContainerJob(input: {
},
);
if (abortController.signal.aborted || isProcessJobCancelling(input.job)) {
updateProcessJob(input.job, {
if (isProcessJobCancelling(input.job)) {
await updateProcessJob(input.job, {
status: "cancelled",
error: input.job.cancelReason ?? "Job cancelled",
});
appendProcessJobLog(input.job, "[job] Cancelled");
await appendProcessJobLog(input.job, "[job] Cancelled");
return;
}
@@ -1628,29 +1650,56 @@ function runInjectedContainerJob(input: {
throw new Error(error || `Job failed with HTTP ${response.statusCode}`);
}
updateProcessJob(input.job, { status: "success", result: payload });
appendProcessJobLog(input.job, "[job] Completed successfully");
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 (
abortController.signal.aborted ||
isProcessJobCancelling(input.job) ||
message === "Command cancelled"
) {
updateProcessJob(input.job, {
await updateProcessJob(input.job, {
status: "cancelled",
error: input.job.cancelReason ?? "Job cancelled",
});
appendProcessJobLog(input.job, "[job] Cancelled");
await appendProcessJobLog(input.job, "[job] Cancelled");
return;
}
updateProcessJob(input.job, { status: "error", error: message });
appendProcessJobLog(input.job, `[job] Failed: ${message}`);
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,
@@ -1662,6 +1711,143 @@ export async function containerRoutes(app: FastifyInstance) {
];
// 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! } },
select: { id: true },
});
if (!container) {
return reply
.status(404)
.send({ success: false, error: "Container not found" });
}
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 },
@@ -1700,7 +1886,7 @@ export async function containerRoutes(app: FastifyInstance) {
.send({ success: false, error: "Job not found" });
}
const cancelled = cancelProcessJob(job);
const cancelled = await cancelProcessJob(job);
return reply.send({
success: true,
@@ -1792,7 +1978,7 @@ export async function containerRoutes(app: FastifyInstance) {
.send({ success: false, error: validationError });
}
const job = createProcessJob({
const job = await createProcessJob({
type: "container_deploy",
userId: req.userId,
organizationId: req.organizationId,
@@ -2196,10 +2382,10 @@ export async function containerRoutes(app: FastifyInstance) {
container.dockerId || container.name,
);
canWriteProjectEnv =
getWritableMountRootsForPath(
normalizedEnvPath,
[...getInspectMounts(inspect), ...storedMounts],
).length > 0;
getWritableMountRootsForPath(normalizedEnvPath, [
...getInspectMounts(inspect),
...storedMounts,
]).length > 0;
} catch {
canWriteProjectEnv = false;
}
@@ -3082,7 +3268,6 @@ export async function containerRoutes(app: FastifyInstance) {
volumes,
network: selectedNetworkName,
});
const updatedContainer = await prisma.container.update({
where: { id: container.id },
data: {
@@ -3092,6 +3277,14 @@ export async function containerRoutes(app: FastifyInstance) {
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,
@@ -3107,14 +3300,22 @@ export async function containerRoutes(app: FastifyInstance) {
message: "Deployment completed successfully",
});
} catch (err) {
const errorMessage = redactSecret(
err instanceof Error ? err.message : String(err),
accessToken,
);
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,
@@ -3150,6 +3351,17 @@ export async function containerRoutes(app: FastifyInstance) {
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,
@@ -3160,14 +3372,22 @@ export async function containerRoutes(app: FastifyInstance) {
});
})
.catch(async (err) => {
const errorMessage = redactSecret(
err instanceof Error ? err.message : String(err),
accessToken,
);
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,
@@ -3290,6 +3510,23 @@ export async function containerRoutes(app: FastifyInstance) {
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,
@@ -3315,11 +3552,32 @@ export async function containerRoutes(app: FastifyInstance) {
: "Deployment completed successfully",
});
} catch (error) {
const errorMessage = redactSecret(
const errorMessage = sanitizeDeploymentError(
ssh.formatDeploymentErrorMessage(error),
accessToken,
{
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,
@@ -3501,7 +3759,7 @@ export async function containerRoutes(app: FastifyInstance) {
.send({ success: false, error: "Container not found" });
}
const job = createProcessJob({
const job = await createProcessJob({
type: "container_rebuild",
userId: req.userId,
organizationId: req.organizationId,
@@ -3545,6 +3803,13 @@ export async function containerRoutes(app: FastifyInstance) {
.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 {
if (container.sourceType === "APP_INSTALLER") {
const install = await prisma.appInstall.findFirst({
@@ -3626,6 +3891,50 @@ export async function containerRoutes(app: FastifyInstance) {
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]) {
@@ -3650,7 +3959,9 @@ export async function containerRoutes(app: FastifyInstance) {
}
}
await ssh.deployContainerFromGitSource(container.server, {
const gitDeploymentResult = await ssh.deployContainerFromGitSource(
container.server,
{
projectName: deploymentProjectName,
repoUrl: container.deploymentSource.repoUrl,
branch: toOptionalValue(container.deploymentSource.repoBranch),
@@ -3686,7 +3997,9 @@ export async function containerRoutes(app: FastifyInstance) {
deploymentPath: toOptionalValue(
container.deploymentSource.deploymentPath,
),
});
},
);
gitDeploymentHeartbeat.assertOwned();
await syncContainersForServers(
[container.server],
@@ -3740,6 +4053,46 @@ export async function containerRoutes(app: FastifyInstance) {
(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,
@@ -3748,6 +4101,16 @@ export async function containerRoutes(app: FastifyInstance) {
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({
@@ -3759,11 +4122,45 @@ export async function containerRoutes(app: FastifyInstance) {
: "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:
err instanceof Error ? err.message : "Failed to rebuild container",
error: errorMessage,
});
} finally {
gitDeploymentHeartbeat?.stop();
if (gitDeploymentLock) {
await releaseDeploymentLock({
containerId: container.id,
token: gitDeploymentLock.token,
}).catch(() => undefined);
}
}
},
);
+23
View File
@@ -0,0 +1,23 @@
function getErrorText(error: unknown) {
return error instanceof Error ? error.message : String(error ?? "");
}
export function formatServerActionError(
error: unknown,
fallbackMessage: string,
) {
const message = getErrorText(error);
const portMatch = message.match(/(?:0\.0\.0\.0|:::):(?:80|443).*?(?:address already in use|already in use)/i);
if (portMatch || /address already in use/i.test(message)) {
return "The service could not start because port 80 or 443 is already in use by another host process or Docker container.";
}
if (/syntax error|unexpected token|command not found|bad substitution/i.test(message)) {
return "The remote host rejected the generated shell command before it could finish. Refresh the server snapshot and retry the action.";
}
return fallbackMessage;
}
@@ -4,6 +4,13 @@ import { auditLog } from "./audit.service";
import { dispatchRuntimeNotification } from "./notification.service";
import * as ssh from "./ssh.service";
import { APP_TEMPLATES } from "../config/app-templates";
import { createDeployment, updateDeployment } from "./deployment.service";
import {
acquireDeploymentLock,
DeploymentLockConflictError,
releaseDeploymentLock,
startDeploymentLockHeartbeat,
} from "./deployment-lock.service";
type DockerInspectRuntime = {
Config?: {
@@ -176,15 +183,51 @@ export async function rebuildAppInstall(options: {
throw new Error("Unable to determine which image should be rebuilt");
}
await prisma.appInstall.update({
where: { id: install.id },
data: { status: "INSTALLING", error: null },
});
let pulledLatestImage = false;
let usedImageCache = false;
let deploymentId: string | null = null;
let deploymentLockToken: string | null = null;
let deploymentLockContainerId: string | null = null;
let deploymentLockHeartbeat: ReturnType<
typeof startDeploymentLockHeartbeat
> | null = null;
try {
const currentContainer = await prisma.container.findFirst({
where: {
serverId: install.serverId,
name: { equals: install.containerName, mode: "insensitive" },
},
select: { id: true },
});
if (currentContainer) {
const lock = await acquireDeploymentLock({ containerId: currentContainer.id });
deploymentLockToken = lock.token;
deploymentLockContainerId = currentContainer.id;
deploymentLockHeartbeat = startDeploymentLockHeartbeat({
containerId: currentContainer.id,
token: lock.token,
});
const deployment = await createDeployment({
containerId: currentContainer.id,
organizationId: options.organizationId ?? install.server.organizationId,
serverId: install.serverId,
userId: options.userId,
status: "RUNNING",
trigger: "REBUILD",
version: runtimeConfig.image,
image: runtimeConfig.image,
configSnapshot: runtimeConfig,
startedAt: new Date(),
});
deploymentId = deployment.id;
}
await prisma.appInstall.update({
where: { id: install.id },
data: { status: "INSTALLING", error: null },
});
try {
await ssh.dockerPullImage(install.server, runtimeConfig.image);
pulledLatestImage = true;
@@ -218,6 +261,8 @@ export async function rebuildAppInstall(options: {
const normalizedDockerId = dockerId.trim().slice(0, 12);
deploymentLockHeartbeat?.assertOwned();
await prisma.container.updateMany({
where: {
serverId: install.serverId,
@@ -238,6 +283,14 @@ export async function rebuildAppInstall(options: {
},
});
if (deploymentId) {
await updateDeployment(deploymentId, {
status: "SUCCESS",
completedAt: new Date(),
image: runtimeConfig.image,
});
}
await auditLog({
userId: options.userId,
serverId: install.serverId,
@@ -283,6 +336,9 @@ export async function rebuildAppInstall(options: {
usedImageCache,
};
} catch (error) {
if (error instanceof DeploymentLockConflictError) {
throw error;
}
const message =
error instanceof Error ? error.message : "Failed to rebuild app";
@@ -299,6 +355,15 @@ export async function rebuildAppInstall(options: {
data: { status: "ERROR" },
});
if (deploymentId) {
await updateDeployment(deploymentId, {
status: "FAILED",
error: message,
completedAt: new Date(),
image: runtimeConfig.image,
}).catch(() => undefined);
}
await auditLog({
userId: options.userId,
serverId: install.serverId,
@@ -335,5 +400,13 @@ export async function rebuildAppInstall(options: {
}
throw error instanceof Error ? error : new Error(message);
} finally {
deploymentLockHeartbeat?.stop();
if (deploymentLockToken && deploymentLockContainerId) {
await releaseDeploymentLock({
containerId: deploymentLockContainerId,
token: deploymentLockToken,
}).catch(() => undefined);
}
}
}
@@ -0,0 +1,126 @@
import type { Server } from "@prisma/client";
import * as ssh from "./ssh.service";
import { sanitizeDeploymentError } from "./deployment-error.service";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_INTERVAL_MS = 2_000;
type DockerInspectHealth = {
State?: {
Status?: string;
Health?: { Status?: string };
};
};
export type ContainerHealthResult = {
healthy: boolean;
status:
| "healthy"
| "unhealthy"
| "starting"
| "not_running"
| "unknown";
reason: string;
attempts: number;
durationMs: number;
};
export function evaluateDockerHealth(
inspect: DockerInspectHealth | null | undefined,
): Omit<ContainerHealthResult, "attempts" | "durationMs"> {
const state = inspect?.State?.Status?.toLowerCase();
if (state === "created" || state === "restarting") {
return {
healthy: false,
status: "starting",
reason: `Container runtime state is ${state}`,
};
}
if (state !== "running") {
return {
healthy: false,
status: "not_running",
reason: `Container runtime state is ${state || "unknown"}`,
};
}
const health = inspect?.State?.Health?.Status?.toLowerCase();
if (health === "unhealthy") {
return {
healthy: false,
status: "unhealthy",
reason: "Docker health check reported unhealthy",
};
}
if (health === "starting") {
return {
healthy: false,
status: "starting",
reason: "Docker health check is still starting",
};
}
if (health === "healthy") {
return {
healthy: true,
status: "healthy",
reason: "Docker health check reported healthy",
};
}
return {
healthy: true,
status: "healthy",
reason: "Container is running and the image has no Docker health check",
};
}
export async function waitForDockerHealth(input: {
server: Server;
containerRef: string;
timeoutMs?: number;
intervalMs?: number;
}): Promise<ContainerHealthResult> {
const startedAt = Date.now();
const timeoutMs = Math.max(1_000, input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const intervalMs = Math.max(250, input.intervalMs ?? DEFAULT_INTERVAL_MS);
let attempts = 0;
let lastReason = "Docker health check did not complete";
while (Date.now() - startedAt <= timeoutMs) {
attempts += 1;
try {
const result = evaluateDockerHealth(
(await ssh.dockerInspect(
input.server,
input.containerRef,
)) as DockerInspectHealth,
);
lastReason = result.reason;
if (result.healthy) {
return { ...result, attempts, durationMs: Date.now() - startedAt };
}
if (
result.status === "unhealthy" ||
result.status === "not_running"
) {
return { ...result, attempts, durationMs: Date.now() - startedAt };
}
} catch (error) {
lastReason = sanitizeDeploymentError(error, {
fallback: "Docker health inspection failed",
});
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return {
healthy: false,
status: "unknown",
reason: `Docker health check timed out after ${timeoutMs}ms: ${lastReason}`,
attempts,
durationMs: Date.now() - startedAt,
};
}
@@ -0,0 +1,76 @@
const SENSITIVE_ASSIGNMENT_PATTERN =
/\b(password|passwd|secret|token|api[-_]?key|private[-_]?key|credential|authorization)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi;
const URL_CREDENTIAL_PATTERN =
/([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^@\s/]+)@/gi;
const SENSITIVE_QUERY_PATTERN =
/([?&](?:access_token|token|api_key|key|secret)=)[^&\s]+/gi;
const PRIVATE_KEY_PATTERN =
/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi;
function errorText(error: unknown) {
return error instanceof Error ? error.message : String(error ?? "");
}
function redactKnownSecrets(message: string, secrets: Array<string | null | undefined>) {
return secrets.reduce<string>((current, secret) => {
const normalized = secret?.trim();
return normalized ? current.split(normalized).join("[REDACTED]") : current;
}, message);
}
export function redactDeploymentErrorDetails(
error: unknown,
secrets: Array<string | null | undefined> = [],
) {
return redactKnownSecrets(errorText(error), secrets)
.replace(PRIVATE_KEY_PATTERN, "[REDACTED PRIVATE KEY]")
.replace(
/\bauthorization\s*:\s*(?:Bearer|Basic)\s+\S+/gi,
"authorization: [REDACTED]",
)
.replace(
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi,
"[REDACTED AUTHORIZATION]",
)
.replace(URL_CREDENTIAL_PATTERN, "$1[REDACTED]@")
.replace(SENSITIVE_QUERY_PATTERN, "$1[REDACTED]")
.replace(SENSITIVE_ASSIGNMENT_PATTERN, "$1=[REDACTED]")
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
.trim();
}
export function sanitizeDeploymentError(
error: unknown,
options: {
fallback?: string;
secrets?: Array<string | null | undefined>;
} = {},
) {
const fallback = options.fallback || "Deployment operation failed";
const message = redactDeploymentErrorDetails(error, options.secrets);
if (!message) return fallback;
if (/address already in use|port is already allocated|bind.*failed/i.test(message)) {
return "The deployment could not start because a required host port is already in use.";
}
if (/no such image|pull access denied|manifest unknown/i.test(message)) {
return "The deployment image could not be resolved or pulled from its registry.";
}
if (/no such container|container .* not found/i.test(message)) {
return "The target container could not be found on the selected server.";
}
if (
/command failed|docker (?:run|pull|inspect|rename|stop|rm)|bash:|sh:|stderr|stdout|prisma|stack|ssh/i.test(
message,
)
) {
return fallback;
}
return message.slice(0, 800);
}
@@ -0,0 +1,130 @@
import { randomUUID } from "node:crypto";
import { Prisma } from "@prisma/client";
import prisma from "../lib/prisma";
export const DEFAULT_DEPLOYMENT_LOCK_TTL_MS = 15 * 60 * 1000;
export const DEFAULT_DEPLOYMENT_LOCK_HEARTBEAT_MS = 60 * 1000;
export class DeploymentLockConflictError extends Error {
constructor() {
super("Another deployment operation is already running for this container");
this.name = "DeploymentLockConflictError";
}
}
export async function acquireDeploymentLock(input: {
containerId: string;
ttlMs?: number;
}) {
const token = randomUUID();
const expiresAt = new Date(
Date.now() +
Math.max(30_000, input.ttlMs ?? DEFAULT_DEPLOYMENT_LOCK_TTL_MS),
);
try {
return await prisma.$transaction(async (tx) => {
const reclaimed = await tx.deploymentLock.updateMany({
where: { containerId: input.containerId, expiresAt: { lte: new Date() } },
data: { token, expiresAt },
});
if (reclaimed.count > 0) return { token, expiresAt };
await tx.deploymentLock.create({
data: { containerId: input.containerId, token, expiresAt },
});
return { token, expiresAt };
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new DeploymentLockConflictError();
}
throw error;
}
}
export async function releaseDeploymentLock(input: {
containerId: string;
token: string;
}) {
await prisma.deploymentLock.deleteMany({
where: { containerId: input.containerId, token: input.token },
});
}
export async function renewDeploymentLock(input: {
containerId: string;
token: string;
ttlMs?: number;
}) {
const result = await prisma.deploymentLock.updateMany({
where: { containerId: input.containerId, token: input.token, expiresAt: { gt: new Date() } },
data: {
expiresAt: new Date(
Date.now() +
Math.max(30_000, input.ttlMs ?? DEFAULT_DEPLOYMENT_LOCK_TTL_MS),
),
},
});
if (result.count !== 1) throw new DeploymentLockConflictError();
}
export function startDeploymentLockHeartbeat(
input: {
containerId: string;
token: string;
ttlMs?: number;
intervalMs?: number;
},
renew = renewDeploymentLock,
) {
const ttlMs = Math.max(
30_000,
input.ttlMs ?? DEFAULT_DEPLOYMENT_LOCK_TTL_MS,
);
const intervalMs = Math.min(
Math.max(10, input.intervalMs ?? DEFAULT_DEPLOYMENT_LOCK_HEARTBEAT_MS),
Math.max(10, Math.floor(ttlMs / 3)),
);
let stopped = false;
let renewing = false;
let ownershipError: Error | null = null;
const timer = setInterval(() => {
if (stopped || renewing || ownershipError) return;
renewing = true;
void renew({
containerId: input.containerId,
token: input.token,
ttlMs,
})
.catch((error: unknown) => {
ownershipError =
error instanceof Error
? error
: new DeploymentLockConflictError();
})
.finally(() => {
renewing = false;
});
}, intervalMs);
timer.unref?.();
return {
assertOwned() {
if (ownershipError) {
throw new DeploymentLockConflictError();
}
},
hasLostOwnership() {
return ownershipError !== null;
},
stop() {
stopped = true;
clearInterval(timer);
},
};
}
@@ -0,0 +1,714 @@
import prisma from "../lib/prisma";
import { auditLog } from "./audit.service";
import {
createDeployment,
getRollbackSnapshot,
updateDeployment,
type DeploymentSnapshot,
} from "./deployment.service";
import * as ssh from "./ssh.service";
import { waitForDockerHealth } from "./container-health.service";
import { resolveDeploymentStrategy } from "./deployment-strategy";
import {
acquireDeploymentLock,
releaseDeploymentLock,
startDeploymentLockHeartbeat,
} from "./deployment-lock.service";
import { sanitizeDeploymentError } from "./deployment-error.service";
type RollbackRuntime = {
image: string;
ports: string;
env: string;
volumes: string;
network: string;
restartPolicy: string;
entrypoint?: string;
commandArgs?: string[];
command: string;
};
type DockerInspectRuntime = {
Config?: {
Image?: string;
Env?: string[];
Entrypoint?: string[] | string | null;
Cmd?: string[] | null;
};
HostConfig?: {
RestartPolicy?: { Name?: string | null };
PortBindings?: Record<
string,
Array<{ HostPort?: string }> | null
>;
NetworkMode?: string | null;
};
Mounts?: Array<{
Source?: string;
Destination?: string;
RW?: boolean;
}>;
};
type DockerPortBindings = NonNullable<
NonNullable<DockerInspectRuntime["HostConfig"]>["PortBindings"]
>;
type RollbackServer = Parameters<typeof ssh.runContainer>[0];
type RollbackRuntimeDependencies = {
runContainer: typeof ssh.runContainer;
stopAndRemoveContainer: typeof stopAndRemoveContainer;
dockerRename: typeof ssh.dockerRename;
waitForDockerHealth: typeof waitForDockerHealth;
};
const rollbackRuntimeDependencies: RollbackRuntimeDependencies = {
runContainer: ssh.runContainer,
stopAndRemoveContainer,
dockerRename: ssh.dockerRename,
waitForDockerHealth,
};
function snapshotString(snapshot: DeploymentSnapshot, key: string, fallback = "") {
const value = snapshot[key];
return typeof value === "string" ? value : fallback;
}
export function normalizeRollbackPortMappings(value: unknown) {
const entries = Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: typeof value === "string"
? value.split(",")
: [];
const normalized = new Map<string, string>();
for (const rawEntry of entries) {
const entry = rawEntry.trim();
if (!entry) continue;
if (!entry.includes("->")) {
normalized.set(entry, entry);
continue;
}
const [publishedRaw, containerRaw] = entry.split("->", 2);
const published = publishedRaw?.trim();
const containerSpec = containerRaw?.trim();
if (!published || !containerSpec) continue;
const [containerPort, protocol = "tcp"] = containerSpec.split("/");
const hostPort = published.match(/(?:^|:)(\d+)$/)?.[1];
if (!hostPort || !/^\d+$/.test(containerPort ?? "")) continue;
const hostIpMatch = published.match(
/^((?:\d{1,3}\.){3}\d{1,3}):\d+$/,
);
const hostIp =
hostIpMatch?.[1] && hostIpMatch[1] !== "0.0.0.0"
? `${hostIpMatch[1]}:`
: "";
const mapping = `${hostIp}${hostPort}:${containerPort}${
protocol !== "tcp" ? `/${protocol}` : ""
}`;
normalized.set(`${hostPort}:${containerPort}/${protocol}`, mapping);
}
return [...normalized.values()].join(",");
}
function storedList(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function formatPortBindings(bindings?: DockerPortBindings) {
if (!bindings) return "";
return Object.entries(bindings)
.flatMap(([containerPortSpec, hostBindings]) => {
if (!hostBindings?.length) return [];
const [containerPort, protocol = "tcp"] = containerPortSpec.split("/");
return hostBindings.flatMap((binding) => {
const hostPort = binding.HostPort?.trim();
if (!hostPort) return [];
return `${hostPort}:${containerPort}${protocol !== "tcp" ? `/${protocol}` : ""}`;
});
})
.join(",");
}
function formatMountBindings(mounts?: DockerInspectRuntime["Mounts"]) {
return (mounts ?? [])
.flatMap((mount) => {
if (!mount.Source || !mount.Destination) return [];
return `${mount.Source}:${mount.Destination}${mount.RW === false ? ":ro" : ""}`;
})
.join(",");
}
function formatEntrypoint(value?: string[] | string | null) {
if (typeof value === "string") return value.trim() || undefined;
return value?.[0]?.trim() || undefined;
}
function snapshotRuntime(
snapshot: DeploymentSnapshot,
fallbackImage?: string | null,
): RollbackRuntime {
const image = (snapshotString(snapshot, "image") || fallbackImage || "").trim();
if (!image) throw new Error("Rollback artifact does not contain an image");
return {
image,
ports: normalizeRollbackPortMappings(snapshot.ports),
env: snapshotString(snapshot, "env"),
volumes: snapshotString(snapshot, "volumes"),
network: snapshotString(snapshot, "network", "bridge"),
restartPolicy: snapshotString(snapshot, "restartPolicy", "unless-stopped"),
entrypoint: snapshotString(snapshot, "entrypoint") || undefined,
commandArgs: storedList(snapshot.commandArgs),
command: snapshotString(snapshot, "command"),
};
}
type RollbackImageDependencies = {
dockerInspect: typeof ssh.dockerInspect;
dockerPullImage: typeof ssh.dockerPullImage;
};
const rollbackImageDependencies: RollbackImageDependencies = {
dockerInspect: ssh.dockerInspect,
dockerPullImage: ssh.dockerPullImage,
};
async function pullOrUseLocalImage(
server: RollbackServer,
image: string,
dependencies: RollbackImageDependencies,
) {
try {
await dependencies.dockerPullImage(server, image);
return image;
} catch (pullError) {
try {
await dependencies.dockerInspect(server, image);
return image;
} catch {
throw pullError;
}
}
}
export async function resolveRollbackImageReference(
input: {
server: RollbackServer;
image: string;
imageDigest?: string | null;
},
dependencies: RollbackImageDependencies = rollbackImageDependencies,
) {
const image = input.image.trim();
const digest = input.imageDigest?.trim() ?? "";
// Existing records store Docker's local image ID (`sha256:...`) in
// imageDigest. It is not a registry manifest digest and must never be
// appended to an image name as `image@sha256:...`.
if (/^sha256:[a-f0-9]{64}$/i.test(digest)) {
try {
await dependencies.dockerInspect(input.server, digest);
return digest;
} catch {
// The local immutable artifact may have been pruned. Fall back to the
// stored image reference so legacy records remain usable when possible.
}
}
if (digest.includes("@sha256:")) {
try {
return await pullOrUseLocalImage(input.server, digest, dependencies);
} catch {
// Fall back to the image reference below for older/incomplete records.
}
}
return pullOrUseLocalImage(input.server, image, dependencies);
}
async function resolvePreviousRuntime(input: {
container: {
name: string;
dockerId: string | null;
image: string;
ports: unknown;
envVars: unknown;
volumes: unknown;
restartPolicy: string;
server: RollbackServer;
};
}): Promise<RollbackRuntime> {
try {
const inspect = (await ssh.dockerInspect(
input.container.server,
input.container.dockerId || input.container.name,
)) as DockerInspectRuntime;
return {
image: inspect.Config?.Image?.trim() || input.container.image,
ports: formatPortBindings(inspect.HostConfig?.PortBindings),
env: (inspect.Config?.Env ?? []).filter(Boolean).join("\n"),
volumes: formatMountBindings(inspect.Mounts),
network: inspect.HostConfig?.NetworkMode?.trim() || "bridge",
restartPolicy:
inspect.HostConfig?.RestartPolicy?.Name?.trim() ||
input.container.restartPolicy ||
"unless-stopped",
entrypoint: formatEntrypoint(inspect.Config?.Entrypoint),
commandArgs: inspect.Config?.Cmd?.filter(
(argument): argument is string => typeof argument === "string",
),
command: "",
};
} catch {
return {
image: input.container.image,
ports: storedList(input.container.ports).join(","),
env: storedList(input.container.envVars).join("\n"),
volumes: storedList(input.container.volumes).join(","),
network: "bridge",
restartPolicy: input.container.restartPolicy || "unless-stopped",
command: "",
};
}
}
async function stopAndRemoveContainer(
server: Parameters<typeof ssh.dockerAction>[0],
candidates: string[],
) {
for (const candidate of candidates.filter(Boolean)) {
try {
await ssh.dockerAction(server, candidate, "stop");
} catch {
// The runtime may already be stopped; removal below is still attempted.
}
try {
await ssh.dockerAction(server, candidate, "rm");
return;
} catch (error) {
if (!/not found|no such container/i.test(String(error))) throw error;
}
}
}
async function waitForRuntimeHealth(input: {
server: RollbackServer;
containerRef: string;
runtime: RollbackRuntime;
label: string;
dependencies: RollbackRuntimeDependencies;
}) {
const health = await input.dependencies.waitForDockerHealth({
server: input.server,
containerRef: input.containerRef,
});
if (!health.healthy) {
throw new Error(`${input.label} runtime check failed: ${health.reason}`);
}
}
function validateRuntimeBeforeMutation(input: {
name: string;
runtime: RollbackRuntime;
ports: string;
}) {
ssh.buildDockerRunCommand({
name: input.name,
image: input.runtime.image,
ports: input.ports,
env: input.runtime.env,
volumes: input.runtime.volumes,
network: input.runtime.network,
restartPolicy: input.runtime.restartPolicy,
entrypoint: input.runtime.entrypoint,
commandArgs: input.runtime.commandArgs,
command: input.runtime.command,
});
}
export class RollbackRuntimeError extends Error {
constructor(
message: string,
readonly recoveryDockerId: string | null,
readonly recoveryMode: "UNCHANGED" | "RECREATED" | "FAILED",
readonly recoveryError: string | null = null,
) {
super(
recoveryError
? `${message} Previous runtime recovery failed: ${recoveryError}`
: message,
);
this.name = "RollbackRuntimeError";
}
}
export async function replaceRuntimeForRollback<TResult = undefined>(
input: {
server: RollbackServer;
containerName: string;
currentContainerRefs: string[];
temporaryName: string;
strategy: ReturnType<typeof resolveDeploymentStrategy>;
targetRuntime: RollbackRuntime;
previousRuntime: RollbackRuntime;
finalize?: (result: {
dockerId: string;
runtimeRef: string;
}) => Promise<TResult>;
},
dependencies: RollbackRuntimeDependencies = rollbackRuntimeDependencies,
) {
const targetName =
input.strategy === "ATOMIC_RENAME"
? input.temporaryName
: input.containerName;
const targetPorts =
input.strategy === "ATOMIC_RENAME" ? "" : input.targetRuntime.ports;
// Both the target and recovery commands must be valid before the active
// runtime is touched. This prevents a deterministic validation error from
// causing avoidable downtime.
validateRuntimeBeforeMutation({
name: targetName,
runtime: input.targetRuntime,
ports: targetPorts,
});
validateRuntimeBeforeMutation({
name: input.containerName,
runtime: input.previousRuntime,
ports: input.previousRuntime.ports,
});
let previousRuntimeTouched = false;
let candidateRef: string | null = null;
try {
if (input.strategy === "RECREATE_WITH_RECOVERY") {
previousRuntimeTouched = true;
await dependencies.stopAndRemoveContainer(
input.server,
input.currentContainerRefs,
);
}
const dockerId = await dependencies.runContainer(input.server, {
name: targetName,
image: input.targetRuntime.image,
ports: targetPorts,
env: input.targetRuntime.env,
volumes: input.targetRuntime.volumes,
network: input.targetRuntime.network,
restartPolicy: input.targetRuntime.restartPolicy,
entrypoint: input.targetRuntime.entrypoint,
commandArgs: input.targetRuntime.commandArgs,
command: input.targetRuntime.command,
});
candidateRef = dockerId.trim() || targetName;
await waitForRuntimeHealth({
server: input.server,
containerRef: candidateRef,
runtime: input.targetRuntime,
label: "Rollback",
dependencies,
});
if (input.strategy === "ATOMIC_RENAME") {
previousRuntimeTouched = true;
await dependencies.stopAndRemoveContainer(
input.server,
input.currentContainerRefs,
);
await dependencies.dockerRename(
input.server,
input.temporaryName,
input.containerName,
);
}
const result = input.finalize
? await input.finalize({
dockerId: dockerId.trim(),
runtimeRef: candidateRef,
})
: (undefined as TResult);
return { dockerId: dockerId.trim(), runtimeRef: candidateRef, result };
} catch (error) {
const message = sanitizeDeploymentError(error, {
fallback: "Rollback runtime replacement failed",
});
if (!previousRuntimeTouched) {
try {
await dependencies.stopAndRemoveContainer(input.server, [
candidateRef ?? "",
input.temporaryName,
]);
const previousRef =
input.currentContainerRefs.find((value) => value.trim()) ??
input.containerName;
await waitForRuntimeHealth({
server: input.server,
containerRef: previousRef,
runtime: input.previousRuntime,
label: "Previous runtime",
dependencies,
});
throw new RollbackRuntimeError(message, previousRef, "UNCHANGED");
} catch (recoveryError) {
if (recoveryError instanceof RollbackRuntimeError) throw recoveryError;
throw new RollbackRuntimeError(
message,
null,
"FAILED",
recoveryError instanceof Error
? sanitizeDeploymentError(recoveryError, {
fallback: "Previous runtime recovery failed",
})
: "Previous runtime recovery failed",
);
}
}
try {
await dependencies.stopAndRemoveContainer(input.server, [
candidateRef ?? "",
input.temporaryName,
input.containerName,
]);
const recoveryDockerId = await dependencies.runContainer(input.server, {
name: input.containerName,
image: input.previousRuntime.image,
ports: input.previousRuntime.ports,
env: input.previousRuntime.env,
volumes: input.previousRuntime.volumes,
network: input.previousRuntime.network,
restartPolicy: input.previousRuntime.restartPolicy,
entrypoint: input.previousRuntime.entrypoint,
commandArgs: input.previousRuntime.commandArgs,
command: input.previousRuntime.command,
});
const recoveryRef = recoveryDockerId.trim() || input.containerName;
await waitForRuntimeHealth({
server: input.server,
containerRef: recoveryRef,
runtime: input.previousRuntime,
label: "Previous runtime",
dependencies,
});
throw new RollbackRuntimeError(
message,
recoveryDockerId.trim(),
"RECREATED",
);
} catch (recoveryError) {
if (recoveryError instanceof RollbackRuntimeError) throw recoveryError;
throw new RollbackRuntimeError(
message,
null,
"FAILED",
recoveryError instanceof Error
? sanitizeDeploymentError(recoveryError, {
fallback: "Previous runtime recovery failed",
})
: "Previous runtime recovery failed",
);
}
}
}
export async function rollbackContainerToDeployment(input: {
containerId: string;
deploymentId: string;
organizationId: string;
userId?: string;
}) {
const container = await prisma.container.findFirst({
where: {
id: input.containerId,
server: { organizationId: input.organizationId },
},
include: { server: true },
});
if (!container) throw new Error("Container not found");
const target = await getRollbackSnapshot(input);
if (!target) {
throw new Error("Only a successful deployment in this container can be rolled back");
}
if (target.serverId !== container.serverId) {
throw new Error("Rollback target belongs to a different server");
}
const runningDeployment = await prisma.deployment.findFirst({
where: {
containerId: container.id,
organizationId: input.organizationId,
status: "RUNNING",
},
select: { id: true },
});
if (runningDeployment) {
throw new Error("Another deployment operation is already running for this container");
}
const runtime = snapshotRuntime(target.snapshot, target.image);
const strategy = resolveDeploymentStrategy(runtime.ports);
const lock = await acquireDeploymentLock({ containerId: container.id });
const lockHeartbeat = startDeploymentLockHeartbeat({
containerId: container.id,
token: lock.token,
});
const rollback = await createDeployment({
containerId: container.id,
organizationId: input.organizationId,
serverId: container.serverId,
userId: input.userId,
status: "RUNNING",
trigger: "ROLLBACK",
version: target.version ?? runtime.image,
image: runtime.image,
imageDigest: target.imageDigest,
configSnapshot: { ...target.snapshot, deploymentStrategy: strategy },
startedAt: new Date(),
}).catch(async (error) => {
lockHeartbeat.stop();
await releaseDeploymentLock({ containerId: container.id, token: lock.token });
throw error;
});
const previousRuntime = await resolvePreviousRuntime({
container,
});
try {
const image = await resolveRollbackImageReference({
server: container.server,
image: runtime.image,
imageDigest: target.imageDigest,
});
runtime.image = image;
const atomicName = `${container.name}-rollback-${rollback.id.slice(-8)}`.slice(0, 128);
const replacement = await replaceRuntimeForRollback({
server: container.server,
containerName: container.name,
currentContainerRefs: [container.dockerId ?? "", container.name],
temporaryName: atomicName,
strategy,
targetRuntime: runtime,
previousRuntime,
finalize: async ({ dockerId }) => {
lockHeartbeat.assertOwned();
const updated = await prisma.container.update({
where: { id: container.id },
data: {
image,
status: "RUNNING",
dockerId: dockerId.trim().slice(0, 12) || null,
},
include: { server: { select: { name: true, ip: true } } },
});
await updateDeployment(rollback.id, {
status: "SUCCESS",
completedAt: new Date(),
image,
imageDigest: target.imageDigest,
});
await auditLog({
userId: input.userId,
organizationId: input.organizationId,
serverId: container.serverId,
action: "CONTAINER_ROLLBACK",
category: "CONTAINER",
level: "SUCCESS",
message: `Container "${container.name}" rolled back to deployment ${target.id}`,
meta: { deploymentId: rollback.id, targetDeploymentId: target.id, image },
});
return updated;
},
});
return {
updated: replacement.result,
deploymentId: rollback.id,
targetDeploymentId: target.id,
};
} catch (error) {
const message = sanitizeDeploymentError(error, {
fallback: "Rollback failed",
});
const runtimeError =
error instanceof RollbackRuntimeError ? error : null;
if (runtimeError?.recoveryMode !== "FAILED") {
await prisma.container.update({
where: { id: container.id },
data: {
status: "RUNNING",
image: previousRuntime.image,
dockerId:
runtimeError?.recoveryDockerId?.trim().slice(0, 12) ||
container.dockerId,
},
});
await auditLog({
userId: input.userId,
organizationId: input.organizationId,
serverId: container.serverId,
action: "CONTAINER_ROLLBACK_RECOVERY",
category: "CONTAINER",
level: "WARNING",
message: `Previous runtime for container "${container.name}" was recovered after rollback failure`,
meta: {
deploymentId: rollback.id,
recovery:
runtimeError?.recoveryMode === "UNCHANGED"
? "UNCHANGED_HEALTHY"
: "RECREATED_HEALTHY",
},
});
} else {
await prisma.container.update({
where: { id: container.id },
data: { status: "ERROR" },
});
await auditLog({
userId: input.userId,
organizationId: input.organizationId,
serverId: container.serverId,
action: "CONTAINER_ROLLBACK_RECOVERY",
category: "CONTAINER",
level: "ERROR",
message: `Previous runtime recovery failed for container "${container.name}"`,
meta: { deploymentId: rollback.id, recovery: "FAILED" },
}).catch(() => undefined);
}
await updateDeployment(rollback.id, {
status: "FAILED",
error: message,
completedAt: new Date(),
}).catch(() => undefined);
throw new Error(message);
} finally {
lockHeartbeat.stop();
await releaseDeploymentLock({ containerId: container.id, token: lock.token });
}
}
@@ -0,0 +1,7 @@
export type DeploymentStrategy = "ATOMIC_RENAME" | "RECREATE_WITH_RECOVERY";
export function resolveDeploymentStrategy(ports: string): DeploymentStrategy {
return ports.trim() ? "RECREATE_WITH_RECOVERY" : "ATOMIC_RENAME";
}
+254
View File
@@ -0,0 +1,254 @@
import {
DeploymentStatus,
DeploymentTrigger,
Prisma,
} from "@prisma/client";
import prisma from "../lib/prisma";
import { decrypt, encrypt } from "../lib/crypto";
const SENSITIVE_KEY_PATTERN = /(pass(word)?|secret|token|api[-_]?key|private[-_]?key|credential)/i;
function redactEnvironmentLine(line: string) {
const separator = line.indexOf("=");
if (separator < 1) return line;
const key = line.slice(0, separator).trim();
return SENSITIVE_KEY_PATTERN.test(key)
? `${key}=<redacted>`
: line;
}
function sanitizeSnapshotValue(value: unknown, key?: string): unknown {
if (key && SENSITIVE_KEY_PATTERN.test(key)) return "<redacted>";
if (typeof value === "string") {
if (key?.toLowerCase().includes("env")) {
return value
.split(/\r?\n/)
.map(redactEnvironmentLine)
.join("\n");
}
return value;
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeSnapshotValue(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [
entryKey,
sanitizeSnapshotValue(entryValue, entryKey),
]),
);
}
return value;
}
export type DeploymentSnapshot = Record<string, unknown>;
export type CreateDeploymentInput = {
containerId: string;
organizationId: string;
serverId: string;
userId?: string;
status: DeploymentStatus;
trigger: DeploymentTrigger;
version?: string | null;
commitSha?: string | null;
branch?: string | null;
image?: string | null;
imageDigest?: string | null;
configSnapshot: DeploymentSnapshot;
error?: string | null;
startedAt?: Date | null;
completedAt?: Date | null;
};
export function sanitizeDeploymentSnapshot(
snapshot: DeploymentSnapshot,
): Prisma.InputJsonValue {
return sanitizeSnapshotValue(snapshot) as Prisma.InputJsonValue;
}
export async function createDeployment(input: CreateDeploymentInput) {
return prisma.deployment.create({
data: {
containerId: input.containerId,
organizationId: input.organizationId,
serverId: input.serverId,
userId: input.userId ?? null,
status: input.status,
trigger: input.trigger,
version: input.version ?? null,
commitSha: input.commitSha ?? null,
branch: input.branch ?? null,
image: input.image ?? null,
imageDigest: input.imageDigest ?? null,
configSnapshot: sanitizeDeploymentSnapshot(input.configSnapshot),
rollbackSnapshotEnc: encrypt(JSON.stringify(input.configSnapshot)),
error: input.error ?? null,
startedAt: input.startedAt ?? null,
completedAt: input.completedAt ?? null,
},
});
}
export async function getRollbackSnapshot(input: {
containerId: string;
deploymentId: string;
organizationId: string;
}) {
const deployment = await prisma.deployment.findFirst({
where: {
id: input.deploymentId,
containerId: input.containerId,
organizationId: input.organizationId,
status: "SUCCESS",
},
select: {
id: true,
containerId: true,
serverId: true,
version: true,
image: true,
imageDigest: true,
configSnapshot: true,
rollbackSnapshotEnc: true,
},
});
if (!deployment) return null;
if (!deployment.rollbackSnapshotEnc) {
throw new Error(
"This deployment has no encrypted rollback artifact. It cannot be restored safely.",
);
}
let snapshot: DeploymentSnapshot;
try {
const parsed: unknown = JSON.parse(decrypt(deployment.rollbackSnapshotEnc));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Invalid rollback snapshot");
}
snapshot = parsed as DeploymentSnapshot;
} catch {
throw new Error("Rollback artifact is invalid or cannot be decrypted");
}
return { ...deployment, snapshot };
}
export async function updateDeployment(
id: string,
patch: {
status: DeploymentStatus;
error?: string | null;
completedAt?: Date | null;
image?: string | null;
imageDigest?: string | null;
commitSha?: string | null;
branch?: string | null;
configSnapshot?: DeploymentSnapshot;
},
) {
return prisma.deployment.update({
where: { id },
data: {
status: patch.status,
error: patch.error ?? null,
completedAt: patch.completedAt ?? null,
...(patch.image === undefined ? {} : { image: patch.image }),
...(patch.imageDigest === undefined
? {}
: { imageDigest: patch.imageDigest }),
...(patch.commitSha === undefined ? {} : { commitSha: patch.commitSha }),
...(patch.branch === undefined ? {} : { branch: patch.branch }),
...(patch.configSnapshot === undefined
? {}
: {
configSnapshot: sanitizeDeploymentSnapshot(patch.configSnapshot),
rollbackSnapshotEnc: encrypt(
JSON.stringify(patch.configSnapshot),
),
}),
},
});
}
export async function listDeployments(input: {
containerId: string;
organizationId: string;
page: number;
pageSize: number;
}) {
const skip = (input.page - 1) * input.pageSize;
const where = {
containerId: input.containerId,
organizationId: input.organizationId,
};
const [items, total] = await prisma.$transaction([
prisma.deployment.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: input.pageSize,
select: {
id: true,
containerId: true,
status: true,
trigger: true,
version: true,
commitSha: true,
branch: true,
image: true,
imageDigest: true,
error: true,
startedAt: true,
completedAt: true,
createdAt: true,
user: { select: { id: true, name: true } },
},
}),
prisma.deployment.count({ where }),
]);
return { items, total, page: input.page, pageSize: input.pageSize };
}
export async function getDeployment(input: {
containerId: string;
deploymentId: string;
organizationId: string;
}) {
return prisma.deployment.findFirst({
where: {
id: input.deploymentId,
containerId: input.containerId,
organizationId: input.organizationId,
},
select: {
id: true,
containerId: true,
serverId: true,
status: true,
trigger: true,
version: true,
commitSha: true,
branch: true,
image: true,
imageDigest: true,
configSnapshot: true,
error: true,
startedAt: true,
completedAt: true,
createdAt: true,
user: { select: { id: true, name: true } },
},
});
}
@@ -1,8 +1,8 @@
import yaml from "js-yaml";
import { randomBytes } from "crypto";
import { posix as pathPosix } from "path";
import { Server } from "@prisma/client";
import { TextDecoder } from "util";
import { Server } from "@prisma/client";
import { execStrict } from "./commands";
import { execDocker, execDockerStrict } from "./internal/docker";
import { privilegedCommand } from "./internal/privilege";
@@ -1140,6 +1140,18 @@ export async function listContainerFiles(
};
}
export async function dockerRename(
server: Server,
containerId: string,
newName: string,
): Promise<void> {
await execDockerStrict(
server,
`docker rename ${escapeShellArg(containerId)} ${escapeShellArg(newName)}`,
shortDockerCommandTimeout(DOCKER_ACTION_TIMEOUT_MS),
);
}
export async function getContainerMainProcessWorkingDirectory(
server: Server,
containerId: string,
@@ -2518,6 +2530,7 @@ export async function deployContainerFromGitSource(
dockerId?: string;
imageTag?: string;
composeFilePath?: string;
commitSha: string;
}> {
const projectName = sanitizeProjectName(opts.projectName);
const deploymentPath =
@@ -2572,6 +2585,24 @@ export async function deployContainerFromGitSource(
throw new Error(formatDeploymentErrorMessage(error));
}
let commitSha = "";
try {
commitSha = (
await execStrict(
server,
privilegedCommand(
server,
`git -C ${escapeShellArg(deploymentPath)} rev-parse HEAD`,
),
shortDockerCommandTimeout(DOCKER_INSPECT_TIMEOUT_MS),
)
).trim();
} catch (error) {
throw new Error(
`Repository was cloned but its commit SHA could not be resolved: ${formatDeploymentErrorMessage(error)}`,
);
}
if (buildType === "COMPOSE") {
try {
await writeComposeEnvFiles({
@@ -2608,6 +2639,7 @@ export async function deployContainerFromGitSource(
return {
deploymentPath,
composeFilePath,
commitSha,
};
}
@@ -2708,6 +2740,7 @@ export async function deployContainerFromGitSource(
deploymentPath,
dockerId,
imageTag,
commitSha,
};
}
@@ -2813,6 +2846,7 @@ export async function deployContainerFromGitSource(
deploymentPath,
dockerId,
imageTag,
commitSha,
};
}
@@ -2851,6 +2885,7 @@ export async function deployContainerFromGitSource(
deploymentPath,
dockerId,
imageTag,
commitSha,
};
}
@@ -2916,5 +2951,6 @@ export async function deployContainerFromGitSource(
deploymentPath,
dockerId,
imageTag,
commitSha,
};
}