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

+4 -1
View File
@@ -35,7 +35,10 @@ PORT=4000
JWT_SECRET=dev-super-secret-jwt-key-change-in-production-32chars
JWT_EXPIRES_IN=7d
# Encryption key for SSH private keys (AES-256)
# Required in production. Generate once, store securely, and keep it stable:
# openssl rand -hex 32
# Changing this value without re-encrypting stored secrets makes existing
# credentials and deployment rollback snapshots unreadable.
ENCRYPTION_KEY=dev-32-char-encryption-key-here!
# /* -------------------------- Public URL / callbacks ------------------------ */
+2 -1
View File
@@ -45,7 +45,8 @@ RATE_LIMIT_TIME_WINDOW=1 minute
# Optional internal backend URL.
# INTERNAL_API_URL=http://127.0.0.1:4000
# Encryption key for SSH private keys (AES-256)
# Development-only example. Production rejects this value.
# Generate a production key with: openssl rand -hex 32
ENCRYPTION_KEY=dev-32-char-encryption-key-here!
# /* -------------------------- Optional integrations ------------------------- */
+31
View File
@@ -69,6 +69,37 @@ Security fixes may be released as maintenance releases:
or, when necessary, included in the next milestone release.
## Production Encryption Key
Doktainer uses `ENCRYPTION_KEY` to encrypt SSH credentials, integration secrets,
and deployment rollback snapshots at rest.
- Production startup requires a non-placeholder key of at least 32 characters.
- Generate a key with a cryptographically secure generator, for example
`openssl rand -hex 32`.
- Store the key in a secret manager or protected runtime environment file. Do
not commit the production value to Git.
- Keep the key stable across application restarts and deployments. Losing it
makes existing encrypted records unreadable.
### Rotation procedure
Do not replace `ENCRYPTION_KEY` directly while encrypted records still use the
old key. A safe rotation requires:
1. Back up the database and verify the backup can be restored.
2. Stop writes that create or update encrypted records.
3. Decrypt every encrypted record with the old key and re-encrypt it with the
new key, including deployment `rollbackSnapshotEnc` values.
4. Verify representative SSH credentials, integrations, 2FA secrets, storage
destinations, and rollback snapshots before switching traffic.
5. Deploy the new key, restart all backend instances, and remove the old key
only after verification succeeds.
The current encrypted payload format does not contain a key identifier.
Operators should therefore treat key rotation as a controlled maintenance
operation until versioned key-ring support is implemented.
## Acknowledgements
We appreciate responsible security researchers and community members who help improve the security of Doktainer.
@@ -0,0 +1,38 @@
CREATE TYPE "DeploymentStatus" AS ENUM ('QUEUED', 'RUNNING', 'SUCCESS', 'FAILED', 'CANCELLED', 'ROLLED_BACK');
CREATE TYPE "DeploymentTrigger" AS ENUM ('MANUAL', 'GIT_WEBHOOK', 'REBUILD', 'ROLLBACK', 'APP_INSTALLER');
CREATE TABLE "deployments" (
"id" TEXT NOT NULL,
"containerId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"userId" TEXT,
"status" "DeploymentStatus" NOT NULL,
"trigger" "DeploymentTrigger" NOT NULL,
"version" TEXT,
"commitSha" TEXT,
"branch" TEXT,
"image" TEXT,
"imageDigest" TEXT,
"configSnapshot" JSONB NOT NULL,
"error" TEXT,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "deployments_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "deployments_containerId_createdAt_idx" ON "deployments"("containerId", "createdAt");
CREATE INDEX "deployments_organizationId_createdAt_idx" ON "deployments"("organizationId", "createdAt");
CREATE INDEX "deployments_serverId_createdAt_idx" ON "deployments"("serverId", "createdAt");
ALTER TABLE "deployments" ADD CONSTRAINT "deployments_containerId_fkey"
FOREIGN KEY ("containerId") REFERENCES "containers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "deployments" ADD CONSTRAINT "deployments_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "deployments" ADD CONSTRAINT "deployments_serverId_fkey"
FOREIGN KEY ("serverId") REFERENCES "servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "deployments" ADD CONSTRAINT "deployments_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,15 @@
CREATE TABLE "deployment_locks" (
"containerId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "deployment_locks_pkey" PRIMARY KEY ("containerId")
);
CREATE UNIQUE INDEX "deployment_locks_token_key" ON "deployment_locks"("token");
CREATE INDEX "deployment_locks_expiresAt_idx" ON "deployment_locks"("expiresAt");
ALTER TABLE "deployment_locks" ADD CONSTRAINT "deployment_locks_containerId_fkey"
FOREIGN KEY ("containerId") REFERENCES "containers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
ALTER TABLE "deployments"
ADD COLUMN "rollbackSnapshotEnc" TEXT;
+66 -1
View File
@@ -31,6 +31,7 @@ model User {
createdOrganizations Organization[] @relation("OrganizationCreator")
apiKeys ApiKey[]
auditLogs AuditLog[]
deployments Deployment[]
settings UserSettings?
notificationProviders UserNotificationProvider[]
integrations UserIntegration[]
@@ -61,6 +62,7 @@ model Organization {
apiKeys ApiKey[]
invitations UserInvitation[]
auditLogs AuditLog[]
deployments Deployment[]
@@index([createdById])
@@map("organizations")
@@ -397,6 +399,7 @@ model Server {
storageDestinations UserStorageDestination[]
domains Domain[]
auditLogs AuditLog[]
deployments Deployment[]
metrics ServerMetric[]
appInstalls AppInstall[]
backups Backup[]
@@ -456,6 +459,8 @@ model Container {
environment Environment? @relation(fields: [environmentId], references: [id], onDelete: SetNull)
domains Domain[]
deploymentSource ContainerDeploymentSource?
deployments Deployment[]
deploymentLock DeploymentLock?
ports Json @default("[]") // [{host: 80, container: 80, protocol: "tcp"}]
envVars Json @default("[]") // [{key: "NODE_ENV", value: "production"}]
volumes Json @default("[]")
@@ -501,6 +506,67 @@ model ContainerDeploymentSource {
@@map("container_deployment_sources")
}
model Deployment {
id String @id @default(cuid())
containerId String
organizationId String
serverId String
userId String?
status DeploymentStatus
trigger DeploymentTrigger
version String?
commitSha String?
branch String?
image String?
imageDigest String?
configSnapshot Json
rollbackSnapshotEnc String?
error String?
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
container Container @relation(fields: [containerId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([containerId, createdAt])
@@index([organizationId, createdAt])
@@index([serverId, createdAt])
@@map("deployments")
}
model DeploymentLock {
containerId String @id
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
container Container @relation(fields: [containerId], references: [id], onDelete: Cascade)
@@index([expiresAt])
@@map("deployment_locks")
}
enum DeploymentStatus {
QUEUED
RUNNING
SUCCESS
FAILED
CANCELLED
ROLLED_BACK
}
enum DeploymentTrigger {
MANUAL
GIT_WEBHOOK
REBUILD
ROLLBACK
APP_INSTALLER
}
model Network {
id String @id @default(cuid())
name String
@@ -781,4 +847,3 @@ enum BackupStatus {
COMPLETED
FAILED
}
+6
View File
@@ -384,6 +384,9 @@ body {
flex: 1 1 240px;
}
.deployment-filter-toolbar { display: flex; align-items: flex-end; justify-content: flex-end; gap: 12px; flex-wrap: wrap; }
.deployment-filter-control { display: grid; gap: 4px; min-width: 160px; flex: 0 1 190px; }
@media (max-width: 768px) {
.ui-responsive-toolbar,
.ui-tab-shell {
@@ -416,6 +419,9 @@ body {
.ui-tab-scroll {
width: 100%;
}
.deployment-filter-toolbar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: end; justify-content: stretch; }
.deployment-filter-control { min-width: 0; width: 100%; flex: none; }
}
/* Gradient text */
@@ -0,0 +1,272 @@
"use client";
import { AlertTriangle, History, X } from "lucide-react";
import type { DeploymentRecord } from "@/lib/api";
interface DeploymentDetailsModalProps {
deployment: DeploymentRecord | null;
loading: boolean;
error: string | null;
onClose: () => void;
}
function formatDate(value: string | null) {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
}
function getStatusClass(status?: DeploymentRecord["status"]) {
if (status === "SUCCESS") return "badge-online";
if (status === "FAILED") return "badge-danger";
return "badge-warning";
}
export default function DeploymentDetailsModal({
deployment,
loading,
error,
onClose,
}: DeploymentDetailsModalProps) {
if (!deployment && !loading && !error) return null;
const metadata = deployment
? [
["Status", deployment.status],
["Trigger", deployment.trigger],
["Commit", deployment.commitSha ?? "-"],
["Branch", deployment.branch ?? "-"],
["Started", formatDate(deployment.startedAt)],
["Completed", formatDate(deployment.completedAt)],
["Actor", deployment.user?.name ?? "System"],
["Image digest", deployment.imageDigest ?? "-"],
]
: [];
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-shell"
style={{ maxWidth: 820 }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="modal-close"
onClick={onClose}
aria-label="Close deployment details"
>
<X size={22} />
</button>
<div
className="modal animate-slide-in"
role="dialog"
aria-modal="true"
aria-labelledby="deployment-details-title"
style={{
width: "100%",
maxWidth: "none",
padding: 24,
display: "grid",
gap: 18,
}}
>
<header
style={{
display: "flex",
alignItems: "center",
gap: 12,
paddingRight: 28,
}}
>
<span
style={{
width: 36,
height: 36,
flex: "0 0 36px",
display: "grid",
placeItems: "center",
borderRadius: 10,
color: "var(--accent-blue)",
background: "rgba(59,130,246,0.1)",
border: "1px solid rgba(59,130,246,0.18)",
}}
>
<History size={18} />
</span>
<div style={{ minWidth: 0 }}>
<p
style={{
margin: 0,
color: "var(--text-muted)",
fontSize: 11,
fontWeight: 600,
}}
>
Deployment details
</p>
<h2
id="deployment-details-title"
style={{
margin: "5px 0 0",
color: "var(--text-primary)",
fontSize: 18,
lineHeight: 1.3,
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
title={deployment?.version ?? "Loading deployment..."}
>
{deployment?.version ?? "Loading deployment..."}
</h2>
</div>
</header>
{loading ? (
<div
style={{
padding: 20,
color: "var(--text-muted)",
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 8,
}}
>
Loading deployment details...
</div>
) : error ? (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "14px 16px",
color: "var(--text-danger)",
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.22)",
borderRadius: 10,
fontSize: 13,
lineHeight: 1.6,
}}
>
<AlertTriangle size={16} style={{ flex: "0 0 auto", marginTop: 2 }} />
<span>{error}</span>
</div>
) : deployment ? (
<>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(min(150px, 100%), 1fr))",
gap: 8,
}}
>
{metadata.map(([label, value]) => (
<div
key={label}
style={{
minWidth: 0,
padding: "10px 12px",
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 8,
}}
>
<p
style={{
margin: 0,
color: "var(--text-muted)",
fontSize: 10,
fontWeight: 600,
}}
>
{label}
</p>
{label === "Status" ? (
<span
className={`ui-badge ${getStatusClass(deployment.status)}`}
style={{ marginTop: 6 }}
>
{value}
</span>
) : (
<p
style={{
margin: "6px 0 0",
color: "var(--text-primary)",
fontSize: 12,
fontFamily:
label === "Commit" || label === "Image digest"
? "var(--font--code)"
: undefined,
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
title={value}
>
{value}
</p>
)}
</div>
))}
</div>
{deployment.error ? (
<div
style={{
padding: "12px 14px",
color: "var(--text-danger)",
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.22)",
borderRadius: 8,
fontSize: 12,
lineHeight: 1.6,
overflowWrap: "anywhere",
}}
>
{deployment.error}
</div>
) : null}
<section style={{ minWidth: 0 }}>
<p
style={{
margin: "0 0 8px",
color: "var(--text-muted)",
fontSize: 11,
fontWeight: 600,
}}
>
Sanitized configuration snapshot
</p>
<pre
style={{
maxHeight: 340,
margin: 0,
padding: 14,
overflow: "auto",
border: "1px solid var(--border)",
borderRadius: 8,
background: "var(--bg-input)",
color: "var(--text-secondary)",
fontSize: 11,
lineHeight: 1.6,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
>
{JSON.stringify(deployment.configSnapshot ?? {}, null, 2)}
</pre>
</section>
</>
) : null}
</div>
</div>
</div>
);
}
@@ -20,6 +20,7 @@ export default function DeploymentSummaryCard({
className="card"
style={{
padding: 14,
minWidth: 0,
minHeight: 104,
display: "flex",
flexDirection: "column",
@@ -42,11 +43,24 @@ export default function DeploymentSummaryCard({
color: "var(--text-primary)",
fontSize: 20,
lineHeight: 1.1,
minWidth: 0,
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
title={item.value}
>
{item.value}
</strong>
<span style={{ color: toneColor[item.tone], fontSize: 11 }}>
<span
style={{
color: toneColor[item.tone],
fontSize: 11,
minWidth: 0,
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
title={item.subvalue}
>
{item.subvalue}
</span>
</section>
@@ -2,19 +2,10 @@
import TablePagination from "@/components/TablePagination";
import { useTablePagination } from "@/lib/use-table-pagination";
import {
CheckCircle2,
Circle,
Clock3,
ExternalLink,
GitBranch,
GitCommit,
Rocket,
XCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Eye, GitBranch, GitCommit, Loader2, RotateCcw } from "lucide-react";
import type {
DeploymentHistoryItem,
DeploymentPipelineStep,
DeploymentTabData,
} from "../../types/app-detail-types";
import PanelShell from "../overview/PanelShell";
@@ -22,6 +13,9 @@ import DeploymentSummaryCard from "./DeploymentSummaryCard";
interface DeploymentsTabPanelProps {
deployments: DeploymentTabData;
onRollback: (deploymentId: string) => void;
onViewDetails: (deploymentId: string) => void;
rollingBackId?: string | null;
}
const statusClass: Record<DeploymentHistoryItem["status"], string> = {
@@ -31,27 +25,29 @@ const statusClass: Record<DeploymentHistoryItem["status"], string> = {
"Rolled Back": "badge-warning",
};
function getStepIcon(status: DeploymentPipelineStep["status"]) {
if (status === "success") return <CheckCircle2 size={15} />;
if (status === "failed") return <XCircle size={15} />;
if (status === "running") return <Clock3 size={15} />;
return <Circle size={15} />;
}
function getStepColor(status: DeploymentPipelineStep["status"]) {
if (status === "success") return "var(--accent-green)";
if (status === "failed") return "var(--text-danger)";
if (status === "running") return "var(--accent-blue)";
return "var(--text-muted)";
}
export default function DeploymentsTabPanel({
deployments,
onRollback,
onViewDetails,
rollingBackId = null,
}: DeploymentsTabPanelProps) {
const [statusFilter, setStatusFilter] = useState<
"ALL" | DeploymentHistoryItem["status"]
>("ALL");
const [triggerFilter, setTriggerFilter] = useState("ALL");
const filteredHistory = useMemo(
() =>
deployments.history.filter(
(item) =>
(statusFilter === "ALL" || item.status === statusFilter) &&
(triggerFilter === "ALL" || item.trigger === triggerFilter),
),
[deployments.history, statusFilter, triggerFilter],
);
const pagination = useTablePagination({
items: deployments.history,
items: filteredHistory,
pageSize: 5,
resetKey: deployments.history.map((item) => item.id).join("|"),
resetKey: `${statusFilter}:${triggerFilter}:${filteredHistory.map((item) => item.id).join("|")}`,
});
return (
@@ -69,184 +65,120 @@ export default function DeploymentsTabPanel({
))}
</div>
<div
style={{
display: "grid",
gridTemplateColumns:
"minmax(min(620px, 100%), 1fr) minmax(min(360px, 100%), 0.8fr)",
gap: 12,
alignItems: "start",
}}
>
<PanelShell title="Latest Deployment">
<div
style={{
display: "grid",
gridTemplateColumns: "auto minmax(0, 1fr)",
minHeight: "160px",
maxHeight: "160px",
gap: 12,
alignItems: "start",
padding: 12,
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--bg-input)",
}}
>
<span
style={{
width: 42,
height: 42,
borderRadius: 10,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "var(--accent-blue)",
background: "rgba(59,130,246,0.1)",
}}
>
<Rocket size={21} />
</span>
<div style={{ minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
}}
>
<h3
style={{
margin: 0,
color: "var(--text-primary)",
fontSize: 16,
}}
>
{deployments.latest.version}
</h3>
<span
className={`ui-badge ${statusClass[deployments.latest.status]}`}
>
{deployments.latest.status}
</span>
</div>
<p
style={{
marginTop: 6,
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.5,
}}
>
Deployed by {deployments.latest.deployedBy} from{" "}
{deployments.latest.source}. This panel reflects the current
container deployment record and runtime inspection state.
</p>
<p
style={{
marginTop: 8,
color: "var(--text-secondary)",
fontFamily: "var(--font--code)",
fontSize: 11,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{deployments.latest.image}
</p>
</div>
</div>
</PanelShell>
<PanelShell title="Deployment Artifacts">
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(min(150px, 100%), 1fr))",
gap: 8,
}}
>
{deployments.artifacts.map((artifact) => (
<div
key={artifact.label}
style={{
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--bg-input)",
padding: "9px 10px",
minWidth: 0,
}}
>
<p
style={{
margin: 0,
color: "var(--text-muted)",
fontSize: 10,
fontWeight: 700,
}}
>
{artifact.label}
</p>
<p
style={{
marginTop: 5,
color: "var(--text-primary)",
fontSize: 12,
fontFamily: "var(--font--code)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{artifact.value}
</p>
<p
style={{
marginTop: 3,
color: "var(--text-muted)",
fontSize: 10,
}}
>
{artifact.meta}
</p>
</div>
))}
</div>
</PanelShell>
</div>
<div
style={{
display: "grid",
gridTemplateColumns:
"minmax(min(620px, 100%), 1fr) minmax(min(360px, 100%), 0.8fr)",
gap: 12,
alignItems: "start",
}}
>
<PanelShell
title={`Deployment History (${deployments.history.length})`}
<PanelShell title={`Deployment History (${filteredHistory.length})`}>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "flex-start",
gap: 12,
flexWrap: "wrap",
marginBottom: 14,
}}
>
<div style={{ overflowX: "auto" }}>
<table className="data-table" style={{ minWidth: 760 }}>
<thead>
<label
style={{
display: "grid",
gap: 4,
minWidth: 160,
flex: "0 1 190px",
color: "var(--text-muted)",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
<select
className="input"
style={{ width: "100%", minHeight: 36, fontSize: 12 }}
value={statusFilter}
onChange={(event) =>
setStatusFilter(event.target.value as typeof statusFilter)
}
aria-label="Filter deployments by status"
>
<option value="ALL">All statuses</option>
<option value="Success">Success</option>
<option value="Failed">Failed</option>
<option value="Running">Running</option>
<option value="Rolled Back">Rolled Back</option>
</select>
</label>
<label
style={{
display: "grid",
gap: 4,
minWidth: 160,
flex: "0 1 190px",
color: "var(--text-muted)",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
<select
className="input"
style={{ width: "100%", minHeight: 36, fontSize: 12 }}
value={triggerFilter}
onChange={(event) => setTriggerFilter(event.target.value)}
aria-label="Filter deployments by trigger"
>
<option value="ALL">All triggers</option>
{[
...new Set(deployments.history.map((item) => item.trigger)),
].map((trigger) => (
<option key={trigger} value={trigger}>
{trigger}
</option>
))}
</select>
</label>
</div>
<div style={{ overflowX: "auto" }}>
<table className="data-table" style={{ minWidth: 900 }}>
<thead>
<tr>
<th>Version</th>
<th>Status</th>
<th>Trigger</th>
<th>Commit</th>
<th>Branch</th>
<th>Duration</th>
<th>Deployed At</th>
<th style={{ textAlign: "right" }}>Action</th>
</tr>
</thead>
<tbody>
{pagination.paginatedItems.length === 0 ? (
<tr>
<th>Version</th>
<th>Status</th>
<th>Trigger</th>
<th>Commit</th>
<th>Branch</th>
<th>Duration</th>
<th>Deployed At</th>
<td
colSpan={8}
style={{
padding: 24,
textAlign: "center",
color: "var(--text-muted)",
}}
>
{deployments.history.length === 0
? "No deployment history yet."
: "No deployments match the selected filters."}
</td>
</tr>
</thead>
<tbody>
{pagination.paginatedItems.map((deployment) => (
) : (
pagination.paginatedItems.map((deployment) => (
<tr key={deployment.id}>
<td style={{ fontFamily: "var(--font--code)" }}>
<td
style={{
maxWidth: 220,
fontFamily: "var(--font--code)",
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
title={deployment.version}
>
{deployment.version}
</td>
<td>
@@ -267,83 +199,61 @@ export default function DeploymentsTabPanel({
</td>
<td>{deployment.duration}</td>
<td>{deployment.deployedAt}</td>
<td>
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
}}
>
<button
type="button"
className="btn btn-ghost btn-small"
onClick={() => onViewDetails(deployment.id)}
title="View deployment details"
aria-label={`View details for deployment ${deployment.version}`}
style={{ padding: "6px 8px", minWidth: 30 }}
>
<Eye size={13} />
</button>
{deployment.canRollback ? (
<button
type="button"
className="btn btn-ghost btn-small"
onClick={() => onRollback(deployment.id)}
disabled={rollingBackId !== null}
title="Rollback to this deployment"
aria-label={`Rollback to deployment ${deployment.version}`}
style={{ padding: "6px 8px", minWidth: 30 }}
>
{rollingBackId === deployment.id ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RotateCcw size={13} />
)}
</button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<TablePagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.totalItems}
startItem={pagination.startItem}
endItem={pagination.endItem}
itemLabel="deployments"
onPageChange={pagination.setCurrentPage}
/>
</PanelShell>
<PanelShell title="Pipeline Timeline">
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{deployments.pipeline.map((step) => (
<div
key={step.id}
style={{
display: "grid",
gridTemplateColumns: "auto minmax(0, 1fr) auto",
gap: 10,
alignItems: "start",
padding: "9px 10px",
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--bg-input)",
}}
>
<span
style={{
color: getStepColor(step.status),
display: "inline-flex",
marginTop: 1,
}}
>
{getStepIcon(step.status)}
</span>
<div style={{ minWidth: 0 }}>
<p
style={{
margin: 0,
color: "var(--text-primary)",
fontSize: 12,
fontWeight: 700,
}}
>
{step.label}
</p>
<p
style={{
marginTop: 3,
color: "var(--text-muted)",
fontSize: 11,
lineHeight: 1.45,
}}
>
{step.description}
</p>
</div>
<span
style={{
color: "var(--text-muted)",
fontSize: 11,
fontFamily: "var(--font--code)",
}}
>
{step.duration}
</span>
</div>
))}
</div>
</PanelShell>
</div>
))
)}
</tbody>
</table>
</div>
<TablePagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.totalItems}
startItem={pagination.startItem}
endItem={pagination.endItem}
itemLabel="deployments"
onPageChange={pagination.setCurrentPage}
/>
</PanelShell>
</section>
);
}
@@ -1,4 +1,8 @@
import type { Container, ContainerDetails } from "@/lib/api";
import type {
Container,
ContainerDetails,
DeploymentRecord,
} from "@/lib/api";
import type {
DeploymentHistoryItem,
DeploymentTabData,
@@ -21,70 +25,71 @@ function getDeploymentStatus(
return "Rolled Back";
}
function getDuration(detail: ContainerDetails | null) {
const startedAt = detail?.inspect.State;
if (
typeof startedAt === "object" &&
startedAt !== null &&
"StartedAt" in startedAt &&
typeof startedAt.StartedAt === "string"
) {
const startedDate = new Date(startedAt.StartedAt);
if (!Number.isNaN(startedDate.getTime())) return "Runtime active";
}
return "-";
}
function getCommit(container: Container) {
if (container.sourceType === "GIT_CLONE") return "Git source";
if (container.sourceType === "GIT_PROVIDER") return "Provider";
return "-";
}
function getBranch(container: Container) {
if (
container.sourceType === "GIT_CLONE" ||
container.sourceType === "GIT_PROVIDER"
) {
return "Configured source";
}
return "-";
}
function getImageTag(image: string) {
if (!image.trim()) return "-";
if (image.includes("@")) return "digest";
const normalized = image.trim();
if (!normalized) return "-";
const lastSegment = image.split("/").at(-1) ?? image;
const digest = normalized.includes("@")
? normalized.split("@").at(-1)
: normalized;
const digestValue = digest ?? "";
if (/^sha256:[a-f0-9]{64}$/i.test(digestValue)) {
return `sha256:${digestValue.slice(7, 19)}…`;
}
const lastSegment = normalized.split("/").at(-1) ?? normalized;
const tagIndex = lastSegment.lastIndexOf(":");
return tagIndex >= 0 ? lastSegment.slice(tagIndex + 1) || "latest" : "latest";
}
function formatDeploymentDuration(item: DeploymentRecord) {
if (!item.startedAt || !item.completedAt) return "-";
const durationMs = new Date(item.completedAt).getTime() - new Date(item.startedAt).getTime();
if (!Number.isFinite(durationMs) || durationMs < 0) return "-";
return `${Math.floor(durationMs / 60000)}m ${Math.floor((durationMs % 60000) / 1000)}s`;
}
function formatDeploymentStatus(
status: DeploymentRecord["status"],
): DeploymentHistoryItem["status"] {
if (status === "SUCCESS") return "Success";
if (status === "FAILED") return "Failed";
if (status === "RUNNING" || status === "QUEUED") return "Running";
return status === "ROLLED_BACK" ? "Rolled Back" : "Failed";
}
function formatDeploymentTrigger(trigger: DeploymentRecord["trigger"]) {
return {
MANUAL: "Manual deploy",
GIT_WEBHOOK: "Git webhook",
REBUILD: "Rebuild",
ROLLBACK: "Rollback",
APP_INSTALLER: "App installer",
}[trigger];
}
function mapDeploymentHistory(items: DeploymentRecord[]) {
return items.map((item) => ({
id: item.id,
version: item.version || item.image || item.id,
status: formatDeploymentStatus(item.status),
trigger: formatDeploymentTrigger(item.trigger),
commit: item.commitSha || "-",
branch: item.branch || "-",
duration: formatDeploymentDuration(item),
deployedAt: formatDate(item.createdAt),
canRollback: item.status === "SUCCESS" && Boolean(item.image),
}));
}
export function createDeploymentsData(
container: Container,
detail: ContainerDetails | null,
deploymentRecords: DeploymentRecord[] = [],
): DeploymentTabData {
const status = getDeploymentStatus(container);
const history: DeploymentHistoryItem[] = [
{
id: container.id,
version: container.name,
status,
trigger:
container.sourceType === "APP_INSTALLER"
? "App installer"
: container.sourceType === "MANUAL"
? "Manual deploy"
: "Git deploy",
commit: getCommit(container),
branch: getBranch(container),
duration: getDuration(detail),
deployedAt: formatDate(container.createdAt),
},
];
const history = mapDeploymentHistory(deploymentRecords);
const latest = history[0];
const runtimeLoaded = detail !== null;
const isSuccessful = status === "Success";
@@ -93,8 +98,8 @@ export function createDeploymentsData(
summaries: [
{
label: "Latest Deployment",
value: latest.version,
subvalue: `${latest.status} - ${latest.deployedAt}`,
value: latest?.version ?? "No deployments",
subvalue: latest ? `${latest.status} - ${latest.deployedAt}` : "No history yet",
tone: isSuccessful ? "green" : "amber",
},
{
@@ -116,68 +121,6 @@ export function createDeploymentsData(
tone: runtimeLoaded ? "blue" : "amber",
},
],
latest: {
version: latest.version,
status: latest.status,
source: container.sourceType ?? "MANUAL",
image: container.image,
deployedBy: "-",
},
history,
pipeline: [
{
id: "source",
label: "Source metadata",
description: `${container.sourceType ?? "MANUAL"} deployment metadata loaded from the container record.`,
status: "success",
duration: "-",
},
{
id: "image",
label: "Image resolved",
description: `Runtime image resolved as ${container.image}.`,
status: container.image ? "success" : "failed",
duration: "-",
},
{
id: "runtime",
label: "Runtime inspected",
description: runtimeLoaded
? "Docker inspect and runtime statistics are available."
: "Runtime inspect is unavailable, usually because the container is stopped or Docker returned an error.",
status: runtimeLoaded ? "success" : "pending",
duration: "-",
},
{
id: "health",
label: "Container status",
description: `Current container status is ${container.status}.`,
status: isSuccessful ? "success" : status === "Failed" ? "failed" : "pending",
duration: "-",
},
],
artifacts: [
{
label: "Image",
value: container.image,
meta: "Runtime image",
},
{
label: "Deploy Mode",
value: container.deployMode ?? "IMAGE",
meta: "Deployment strategy",
},
{
label: "Restart Policy",
value: container.restartPolicy || "unless-stopped",
meta: "Docker policy",
},
{
label: "Server",
value: detail?.server.name ?? container.server?.name ?? container.serverId,
meta: detail?.server.ip ?? container.server?.ip ?? "Target host",
},
],
hasHistoricalData: false,
};
}
@@ -18,6 +18,7 @@ import {
type ContainerProcess,
type ContainerProjectEnvFile,
type ContainerRuntimeStats,
type DeploymentRecord,
type Domain,
type ProjectEnvironmentRecord,
type ProjectRecord,
@@ -25,6 +26,7 @@ import {
import ContainerFileManagerModal from "@/components/containers/modals/ContainerFileManagerModal";
import AdvancedTabPanel from "./components/advanced/AdvancedTabPanel";
import DeploymentsTabPanel from "./components/deployments/DeploymentsTabPanel";
import DeploymentDetailsModal from "./components/deployments/DeploymentDetailsModal";
import EnvironmentTabPanel from "./components/environment/EnvironmentTabPanel";
import AppDetailHeader from "./components/header/AppDetailHeader";
import LogsTabPanel from "./components/logs/LogsTabPanel";
@@ -467,6 +469,16 @@ export default function AppContainerDetailPage() {
);
const [loading, setLoading] = useState(true);
const [acting, setActing] = useState(false);
const [rollingBackDeploymentId, setRollingBackDeploymentId] = useState<
string | null
>(null);
const [selectedDeployment, setSelectedDeployment] =
useState<DeploymentRecord | null>(null);
const [deploymentDetailLoading, setDeploymentDetailLoading] =
useState(false);
const [deploymentDetailError, setDeploymentDetailError] = useState<
string | null
>(null);
const [activeAction, setActiveAction] = useState<AppAction["id"] | null>(
null,
);
@@ -491,6 +503,7 @@ export default function AppContainerDetailPage() {
const runtimeMetricsInFlightRef = useRef(false);
const runtimeDetailRef = useRef<ContainerDetails | null>(null);
const environmentRecordRef = useRef<ProjectEnvironmentRecord | null>(null);
const deploymentHistoryRef = useRef<DeploymentRecord[]>([]);
const projectEnvRef = useRef<ContainerProjectEnvFile | null>(null);
const domainsLoadedRef = useRef(false);
const domainsLoadingRef = useRef(false);
@@ -518,6 +531,7 @@ export default function AppContainerDetailPage() {
runtimeDetailLoadedRef.current = false;
projectEnvLoadedRef.current = false;
logsLoadedRef.current = false;
deploymentHistoryRef.current = [];
try {
const [projectResponse, containerResponse, metricsResult] =
@@ -614,6 +628,105 @@ export default function AppContainerDetailPage() {
containerRecordRef.current = containerRecord;
}, [containerRecord]);
const hydrateDeployments = useCallback(async () => {
const container = containerRecordRef.current;
if (!container) return;
try {
const response = await containersApi.deployments(params.containerId, {
page: 1,
pageSize: 50,
});
deploymentHistoryRef.current = response.data.items;
setAppDetail((current) =>
current
? {
...current,
deployments: createDeploymentsData(
container,
runtimeDetailRef.current,
response.data.items,
),
}
: current,
);
} catch {
// The detail page remains usable if deployment history is unavailable.
}
}, [params.containerId]);
const runRollback = useCallback(
async (deploymentId: string) => {
if (acting || rollingBackDeploymentId) return;
setRollingBackDeploymentId(deploymentId);
setActionError("");
try {
await containersApi.rollbackDeployment(params.containerId, deploymentId);
await load();
setActiveTab("deployments");
} catch (rollbackError) {
setActionError(
rollbackError instanceof Error
? rollbackError.message
: "Rollback failed",
);
} finally {
setRollingBackDeploymentId(null);
}
},
[acting, load, params.containerId, rollingBackDeploymentId],
);
const requestRollback = useCallback(
(deploymentId: string) => {
const deployment = appDetail?.deployments.history.find(
(item) => item.id === deploymentId,
);
if (!deployment || !deployment.canRollback) return;
setConfirmDialog({
title: "Rollback Deployment",
description: `Rollback container "${appDetail?.name ?? "this container"}" to ${deployment.version}?`,
confirmLabel: "Rollback Deployment",
tone: "warning",
note: "The current container will be replaced using the selected deployment artifact. Doktainer will attempt to recover the current runtime if rollback fails.",
onConfirm: () => {
void runRollback(deploymentId);
},
});
},
[appDetail, runRollback],
);
const openDeploymentDetails = useCallback(
async (deploymentId: string) => {
setSelectedDeployment(null);
setDeploymentDetailError(null);
setDeploymentDetailLoading(true);
try {
const response = await containersApi.deployment(
params.containerId,
deploymentId,
);
setSelectedDeployment(response.data);
} catch (detailsError) {
setDeploymentDetailError(
detailsError instanceof Error
? detailsError.message
: "Failed to load deployment details",
);
} finally {
setDeploymentDetailLoading(false);
}
},
[params.containerId],
);
useEffect(() => {
if (containerRecord) void hydrateDeployments();
}, [containerRecord, hydrateDeployments]);
const refreshRuntimeMetrics = useCallback(async () => {
const container = containerRecordRef.current;
if (!container || container.status !== "RUNNING") return;
@@ -757,7 +870,11 @@ export default function AppContainerDetailPage() {
serverIp: detail.server.ip,
metrics: buildMetrics(detail, container),
runtime: createRuntimeData(container, detail),
deployments: createDeploymentsData(container, detail),
deployments: createDeploymentsData(
container,
detail,
deploymentHistoryRef.current,
),
advanced: createAdvancedData(container, detail),
environment: createEnvironmentData({
container,
@@ -940,6 +1057,7 @@ export default function AppContainerDetailPage() {
return;
}
setActionError("");
window.open(appDetail.openUrl, "_blank", "noopener,noreferrer");
}, [appDetail]);
@@ -1432,6 +1550,15 @@ export default function AppContainerDetailPage() {
current?.onConfirm();
}}
/>
<DeploymentDetailsModal
deployment={selectedDeployment}
loading={deploymentDetailLoading}
error={deploymentDetailError}
onClose={() => {
setSelectedDeployment(null);
setDeploymentDetailError(null);
}}
/>
<ProcessLogsModal
open={isProcessLogsOpen}
onClose={closeProcessLogs}
@@ -1544,7 +1671,12 @@ export default function AppContainerDetailPage() {
/>
) : activeTab === "terminal" ? null : activeTab ===
"deployments" ? (
<DeploymentsTabPanel deployments={appDetail.deployments} />
<DeploymentsTabPanel
deployments={appDetail.deployments}
onRollback={requestRollback}
onViewDetails={openDeploymentDetails}
rollingBackId={rollingBackDeploymentId}
/>
) : activeTab === "advanced" ? (
<AdvancedTabPanel
advanced={appDetail.advanced}
@@ -144,35 +144,12 @@ export interface DeploymentHistoryItem {
branch: string;
duration: string;
deployedAt: string;
}
export interface DeploymentPipelineStep {
id: string;
label: string;
description: string;
status: "success" | "running" | "pending" | "failed";
duration: string;
}
export interface DeploymentArtifact {
label: string;
value: string;
meta: string;
canRollback?: boolean;
}
export interface DeploymentTabData {
summaries: DeploymentTabSummary[];
latest: {
version: string;
status: DeploymentHistoryItem["status"];
source: string;
image: string;
deployedBy: string;
};
history: DeploymentHistoryItem[];
pipeline: DeploymentPipelineStep[];
artifacts: DeploymentArtifact[];
hasHistoricalData: boolean;
}
export interface AdvancedSummary {
+78 -16
View File
@@ -1,5 +1,5 @@
/**
* API Client — DOKTAINER
* API Client — DOKTAINER
* Centralized HTTP client with auth token injection
*/
@@ -130,7 +130,7 @@ function handleUnauthorizedSession(
throw new Error("Unauthorized - session expired");
}
// ─── Token helpers ────────────────────────────────────────────────────────────
// ─── Token helpers ────────────────────────────────────────────────────────────
export function getToken(): string | null {
return getSensitiveStorageItem(sensitiveStorageKeys.token);
@@ -165,7 +165,7 @@ export function setUser(user: UserInfo): void {
emitAuthStateChanged();
}
// ─── Types ────────────────────────────────────────────────────────────────────
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UserInfo {
id: string;
@@ -628,7 +628,7 @@ async function readApiErrorMessage(
}
}
// ─── Core fetch wrapper ───────────────────────────────────────────────────────
// ─── Core fetch wrapper ───────────────────────────────────────────────────────
async function request<T>(
path: string,
@@ -754,7 +754,7 @@ function del<T>(path: string, body?: unknown, config?: { timeoutMs?: number }) {
);
}
// ─── WebSocket helper ─────────────────────────────────────────────────────────
// ─── WebSocket helper ─────────────────────────────────────────────────────────
export function createWs(path: string): WebSocket {
const token = getToken();
@@ -764,7 +764,7 @@ export function createWs(path: string): WebSocket {
);
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
// ─── Auth ─────────────────────────────────────────────────────────────────────
export const auth = {
registrationStatus: () =>
@@ -858,7 +858,7 @@ export const auth = {
),
};
// ─── Users ───────────────────────────────────────────────────────────────────
// ─── Users ───────────────────────────────────────────────────────────────────
export type UserRole = "SUPER_ADMIN" | "OPERATOR" | "DEVELOPER" | "VIEWER";
@@ -1244,7 +1244,7 @@ export const gitProvidersApi = {
),
};
// ─── Servers ──────────────────────────────────────────────────────────────────
// ─── Servers ──────────────────────────────────────────────────────────────────
export interface Server {
id: string;
@@ -1536,7 +1536,7 @@ export const servers = {
),
};
// ─── Containers ───────────────────────────────────────────────────────────────
// ─── Containers ───────────────────────────────────────────────────────────────
export interface Container {
id: string;
@@ -1613,6 +1613,40 @@ export interface ContainerDetails {
processes: ContainerProcess[];
}
export type DeploymentStatus =
| "QUEUED"
| "RUNNING"
| "SUCCESS"
| "FAILED"
| "CANCELLED"
| "ROLLED_BACK";
export type DeploymentTrigger =
| "MANUAL"
| "GIT_WEBHOOK"
| "REBUILD"
| "ROLLBACK"
| "APP_INSTALLER";
export interface DeploymentRecord {
id: string;
containerId: string;
serverId?: string;
status: DeploymentStatus;
trigger: DeploymentTrigger;
version: string | null;
commitSha: string | null;
branch: string | null;
image: string | null;
imageDigest: string | null;
configSnapshot?: Record<string, unknown>;
error: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
user: { id: string; name: string } | null;
}
export interface ContainerMetrics {
container: {
id: string;
@@ -1786,6 +1820,35 @@ export const containers = {
},
get: (id: string) =>
get<{ success: boolean; data: Container }>(`/containers/${id}`),
deployments: (id: string, params?: { page?: number; pageSize?: number }) => {
const qs = new URLSearchParams();
if (params?.page) qs.set("page", String(params.page));
if (params?.pageSize) qs.set("pageSize", String(params.pageSize));
const query = qs.toString();
return get<{
success: boolean;
data: {
items: DeploymentRecord[];
total: number;
page: number;
pageSize: number;
};
}>(`/containers/${id}/deployments${query ? `?${query}` : ""}`);
},
rollbackDeployment: (containerId: string, deploymentId: string) =>
post<{
success: boolean;
data: Container;
meta?: {
rollbackDeploymentId: string;
targetDeploymentId: string;
};
message?: string;
}>(`/containers/${containerId}/deployments/${deploymentId}/rollback`, {}),
deployment: (containerId: string, deploymentId: string) =>
get<{ success: boolean; data: DeploymentRecord }>(
`/containers/${containerId}/deployments/${deploymentId}`,
),
deploy: (body: ContainerDeployBody) =>
post<{
success: boolean;
@@ -2003,7 +2066,7 @@ export const containers = {
),
};
// ─── Metrics ──────────────────────────────────────────────────────────────────
// ─── Metrics ──────────────────────────────────────────────────────────────────
export interface DashboardOverview {
servers: { total: number; online: number };
@@ -2059,7 +2122,7 @@ export const metrics = {
),
};
// ─── Domains ──────────────────────────────────────────────────────────────────
// ─── Domains ──────────────────────────────────────────────────────────────────
export interface Domain {
id: string;
@@ -2450,7 +2513,7 @@ export const security = {
),
};
// ─── Apps ────────────────────────────────────────────────────────────────────
// ─── Apps ────────────────────────────────────────────────────────────────────
export interface AppTemplate {
id: string;
@@ -2649,7 +2712,7 @@ export const apps = {
}),
};
// ─── Backups ──────────────────────────────────────────────────────────────────
// ─── Backups ──────────────────────────────────────────────────────────────────
export interface Backup {
id: string;
@@ -2750,7 +2813,7 @@ export const backups = {
delete: (id: string) => del<{ success: boolean }>(`/backups/${id}`),
};
// ─── Logs ─────────────────────────────────────────────────────────────────────
// ─── Logs ─────────────────────────────────────────────────────────────────────
export const logs = {
list: (params?: {
@@ -2775,7 +2838,7 @@ export const logs = {
},
};
// ─── Terminal ─────────────────────────────────────────────────────────────────
// ─── Terminal ─────────────────────────────────────────────────────────────────
export const terminal = {
sessions: () =>
@@ -2847,4 +2910,3 @@ export const apiClient = {
terminal,
};
export default apiClient;
+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,
};
}
+31
View File
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { evaluateDockerHealth } from "../../src/server/services/container-health.service";
test("docker health gate accepts a running image without a healthcheck", () => {
const result = evaluateDockerHealth({ State: { Status: "running" } });
assert.equal(result.healthy, true);
assert.equal(result.status, "healthy");
});
test("docker health gate rejects unhealthy containers", () => {
const result = evaluateDockerHealth({
State: { Status: "running", Health: { Status: "unhealthy" } },
});
assert.equal(result.healthy, false);
assert.equal(result.status, "unhealthy");
});
test("docker health gate rejects containers that are not running", () => {
const result = evaluateDockerHealth({ State: { Status: "exited" } });
assert.equal(result.healthy, false);
assert.equal(result.status, "not_running");
});
test("docker runtime verification keeps polling while a container is restarting", () => {
const result = evaluateDockerHealth({ State: { Status: "restarting" } });
assert.equal(result.healthy, false);
assert.equal(result.status, "starting");
});
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DeploymentLockConflictError,
startDeploymentLockHeartbeat,
} from "../../src/server/services/deployment-lock.service";
test("deployment lock heartbeat renews with the owner token and detects ownership loss", async () => {
const renewals: Array<{ containerId: string; token: string }> = [];
let shouldFail = false;
const heartbeat = startDeploymentLockHeartbeat(
{
containerId: "container-1",
token: "owner-token",
ttlMs: 30_000,
intervalMs: 10,
},
async (input) => {
renewals.push({
containerId: input.containerId,
token: input.token,
});
if (shouldFail) throw new DeploymentLockConflictError();
},
);
await new Promise((resolve) => setTimeout(resolve, 25));
assert.ok(renewals.length >= 1);
assert.deepEqual(renewals[0], {
containerId: "container-1",
token: "owner-token",
});
heartbeat.assertOwned();
shouldFail = true;
await new Promise((resolve) => setTimeout(resolve, 25));
assert.equal(heartbeat.hasLostOwnership(), true);
assert.throws(() => heartbeat.assertOwned(), DeploymentLockConflictError);
heartbeat.stop();
});
+22
View File
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import { sanitizeDeploymentSnapshot } from "../../src/server/services/deployment.service";
test("deployment snapshots redact sensitive configuration values", () => {
const snapshot = sanitizeDeploymentSnapshot({
env: "APP_ENV=production\nDATABASE_PASSWORD=super-secret\nPUBLIC_URL=https://example.test",
accessToken: "provider-token",
nested: { apiKey: "another-secret" },
image: "nginx:latest",
}) as Record<string, unknown>;
assert.equal(
snapshot.env,
"APP_ENV=production\nDATABASE_PASSWORD=<redacted>\nPUBLIC_URL=https://example.test",
);
assert.equal(snapshot.accessToken, "<redacted>");
assert.deepEqual(snapshot.nested, { apiKey: "<redacted>" });
assert.equal(snapshot.image, "nginx:latest");
});
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const CONTAINER_ROUTES = "src/server/routes/containers.ts";
const APP_ROUTES = "src/server/routes/apps.ts";
const DOCKER_CONTAINERS =
"src/server/services/ssh-services/docker-containers.ts";
function readSource(path: string) {
return readFileSync(path, "utf8");
}
function sourceBlock(source: string, startPattern: string, endPattern: string) {
const start = source.indexOf(startPattern);
assert.notEqual(start, -1, `Missing source block start: ${startPattern}`);
const end = source.indexOf(endPattern, start);
assert.notEqual(end, -1, `Missing source block end: ${endPattern}`);
return source.slice(start, end);
}
test("Git rebuild follows the deployment history lifecycle", () => {
const source = readSource(CONTAINER_ROUTES);
const rebuildRoute = sourceBlock(
source,
'"/:id/rebuild"',
"GET /containers/:id/logs",
);
const acquireIndex = rebuildRoute.indexOf("acquireDeploymentLock");
const runningIndex = rebuildRoute.indexOf('status: "RUNNING"');
const mutationIndex = rebuildRoute.indexOf("deployContainerFromGitSource");
const successIndex = rebuildRoute.indexOf('status: "SUCCESS"', mutationIndex);
assert.ok(acquireIndex >= 0);
assert.ok(runningIndex > acquireIndex);
assert.ok(mutationIndex > runningIndex);
assert.ok(successIndex > mutationIndex);
assert.doesNotMatch(rebuildRoute, /waitForDeploymentHealth/);
assert.match(rebuildRoute, /status: "FAILED"/);
assert.match(rebuildRoute, /releaseDeploymentLock/);
assert.match(rebuildRoute, /commitSha: gitDeploymentResult\.commitSha/);
assert.match(rebuildRoute, /imageDigest/);
});
test("Fresh App Installer creates a provisional parent and RUNNING deployment before Docker mutation", () => {
const source = readSource(APP_ROUTES);
const installRoute = sourceBlock(
source,
'app.post("/install"',
'"/installs/:id/action"',
);
const provisionalIndex = installRoute.indexOf(
"const createdContainer = await tx.container.create",
);
const runningIndex = installRoute.indexOf('status: "RUNNING"');
const mutationIndex = installRoute.indexOf(
"const dockerId = await ssh.runContainer",
);
assert.ok(provisionalIndex >= 0);
assert.ok(runningIndex > provisionalIndex);
assert.ok(mutationIndex > runningIndex);
assert.match(installRoute, /where: \{ id: deployment\.id \}/);
assert.match(installRoute, /status: "FAILED"/);
assert.match(installRoute, /where: \{ id: provisionalContainer\.id \}/);
});
test("Git deployment resolves and returns the cloned commit SHA", () => {
const source = readSource(DOCKER_CONTAINERS);
const start = source.indexOf(
"export async function deployContainerFromGitSource",
);
assert.notEqual(start, -1);
const gitDeployFunction = source.slice(start);
assert.match(gitDeployFunction, /git -C .* rev-parse HEAD/);
assert.match(gitDeployFunction, /commitSha: string/);
assert.match(gitDeployFunction, /commitSha,/);
});
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { sanitizeDeploymentSnapshot } from "../../src/server/services/deployment.service";
test("deployment snapshot keeps rollback-safe runtime fields sanitized", () => {
const snapshot = sanitizeDeploymentSnapshot({
image: "example/app@sha256:abc",
ports: "8080:80",
env: "APP_ENV=production\nAPI_TOKEN=secret",
volumes: "/data:/data",
}) as Record<string, unknown>;
assert.equal(snapshot.image, "example/app@sha256:abc");
assert.equal(snapshot.env, "APP_ENV=production\nAPI_TOKEN=<redacted>");
});
test("deployment migrations include the history and rollback schema", () => {
const migrationsPath = join(process.cwd(), "prisma", "migrations");
const migrations = readdirSync(migrationsPath);
assert.ok(migrations.some((name) => name.includes("add_deployment_history")));
assert.ok(migrations.some((name) => name.includes("add_deployment_rollback_snapshot")));
assert.ok(migrations.some((name) => name.includes("add_deployment_locks")));
assert.equal(
migrations.some((name) =>
/deployment_attempt|process_job|service_lease|deployment_health_policy/.test(
name,
),
),
false,
);
const schema = readFileSync(join(process.cwd(), "prisma", "schema.prisma"), "utf8");
assert.match(schema, /model Deployment\s*\{/);
assert.match(schema, /rollbackSnapshotEnc\s+String\?/);
assert.doesNotMatch(schema, /model DeploymentAttempt\s*\{/);
assert.doesNotMatch(schema, /model ProcessJob\s*\{/);
assert.doesNotMatch(schema, /model ServiceLease\s*\{/);
});
@@ -0,0 +1,275 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
normalizeRollbackPortMappings,
replaceRuntimeForRollback,
resolveRollbackImageReference,
RollbackRuntimeError,
} from "../../src/server/services/deployment-rollback.service";
type RollbackInput = Parameters<typeof replaceRuntimeForRollback>[0];
type RollbackDependencies = NonNullable<
Parameters<typeof replaceRuntimeForRollback>[1]
>;
const server = {} as RollbackInput["server"];
test("normalizes Docker display port mappings for rollback", () => {
assert.equal(
normalizeRollbackPortMappings(
"0.0.0.0:8080->80/tcp, [::]:8080->80/tcp, 127.0.0.1:8443->443/tcp",
),
"8080:80,127.0.0.1:8443:443",
);
assert.equal(
normalizeRollbackPortMappings(["8081:81", "9000->90/udp"]),
"8081:81,9000:90/udp",
);
});
test("uses a locally available Docker image ID without creating an invalid registry reference", async () => {
const inspected: string[] = [];
const pulled: string[] = [];
const image = await resolveRollbackImageReference(
{
server,
image: "example/app:latest",
imageDigest: `sha256:${"a".repeat(64)}`,
},
{
dockerInspect: async (_server, imageRef) => {
inspected.push(imageRef);
return {};
},
dockerPullImage: async (_server, imageRef) => {
pulled.push(imageRef);
},
},
);
assert.equal(image, `sha256:${"a".repeat(64)}`);
assert.deepEqual(inspected, [`sha256:${"a".repeat(64)}`]);
assert.deepEqual(pulled, []);
});
test("falls back to the stored image when a historical local image ID was pruned", async () => {
const pulled: string[] = [];
const image = await resolveRollbackImageReference(
{
server,
image: "example/app:stable",
imageDigest: `sha256:${"b".repeat(64)}`,
},
{
dockerInspect: async () => {
throw new Error("image not found");
},
dockerPullImage: async (_server, imageRef) => {
pulled.push(imageRef);
},
},
);
assert.equal(image, "example/app:stable");
assert.deepEqual(pulled, ["example/app:stable"]);
});
function runtime(overrides: Partial<RollbackInput["targetRuntime"]> = {}) {
return {
image: "example/app:target",
ports: "",
env: "APP_ENV=production",
volumes: "/data:/data",
network: "bridge",
restartPolicy: "unless-stopped",
command: "",
...overrides,
};
}
function dependencies(
overrides: Partial<RollbackDependencies> = {},
): RollbackDependencies {
return {
runContainer: async () => "new-container-id",
stopAndRemoveContainer: async () => undefined,
dockerRename: async () => undefined,
waitForDockerHealth: async () => ({
healthy: true,
reason: "running",
status: "healthy",
attempts: 1,
durationMs: 0,
}),
...overrides,
};
}
test("published-port rollback removes the active runtime before claiming its name and host port", async () => {
const events: string[] = [];
await replaceRuntimeForRollback(
{
server,
containerName: "app",
currentContainerRefs: ["old-container-id", "app"],
temporaryName: "app-rollback",
strategy: "RECREATE_WITH_RECOVERY",
targetRuntime: runtime({ ports: "8080:80" }),
previousRuntime: runtime({
image: "example/app:previous",
ports: "8080:80",
}),
},
dependencies({
stopAndRemoveContainer: async (_server, refs) => {
events.push(`remove:${refs.join(",")}`);
},
runContainer: async (_server, options) => {
events.push(`run:${options.name}:${options.ports}`);
return "new-container-id";
},
waitForDockerHealth: async () => {
events.push("runtime-check");
return {
healthy: true,
reason: "running",
status: "healthy",
attempts: 1,
durationMs: 0,
};
},
}),
);
assert.deepEqual(events, [
"remove:old-container-id,app",
"run:app:8080:80",
"runtime-check",
]);
});
test("failed published-port rollback recreates and verifies the previous runtime", async () => {
const events: string[] = [];
let runCount = 0;
await assert.rejects(
replaceRuntimeForRollback(
{
server,
containerName: "app",
currentContainerRefs: ["old-container-id", "app"],
temporaryName: "app-rollback",
strategy: "RECREATE_WITH_RECOVERY",
targetRuntime: runtime({ ports: "8080:80" }),
previousRuntime: runtime({
image: "example/app:previous",
ports: "8080:80",
}),
},
dependencies({
stopAndRemoveContainer: async (_server, refs) => {
events.push(`remove:${refs.join(",")}`);
},
runContainer: async (_server, options) => {
runCount += 1;
events.push(`run:${options.image}`);
return runCount === 1 ? "failed-target-id" : "recovered-container-id";
},
waitForDockerHealth: async ({ containerRef }) => {
events.push(`runtime-check:${containerRef}`);
if (containerRef === "failed-target-id") {
return {
healthy: false,
reason: "not running",
status: "not_running",
attempts: 1,
durationMs: 0,
};
}
return {
healthy: true,
reason: "running",
status: "healthy",
attempts: 1,
durationMs: 0,
};
},
}),
),
(error: unknown) => {
assert.ok(error instanceof RollbackRuntimeError);
assert.equal(error.recoveryMode, "RECREATED");
assert.equal(error.recoveryDockerId, "recovered-container-id");
return true;
},
);
assert.deepEqual(events, [
"remove:old-container-id,app",
"run:example/app:target",
"runtime-check:failed-target-id",
"remove:failed-target-id,app-rollback,app",
"run:example/app:previous",
"runtime-check:recovered-container-id",
]);
});
test("failed atomic candidate leaves the active runtime in place", async () => {
const removedRefs: string[][] = [];
let runCount = 0;
await assert.rejects(
replaceRuntimeForRollback(
{
server,
containerName: "app",
currentContainerRefs: ["old-container-id", "app"],
temporaryName: "app-rollback",
strategy: "ATOMIC_RENAME",
targetRuntime: runtime(),
previousRuntime: runtime({ image: "example/app:previous" }),
},
dependencies({
stopAndRemoveContainer: async (_server, refs) => {
removedRefs.push(refs);
},
runContainer: async () => {
runCount += 1;
return "failed-target-id";
},
waitForDockerHealth: async ({ containerRef }) => {
if (containerRef === "failed-target-id") {
return {
healthy: false,
reason: "not running",
status: "not_running",
attempts: 1,
durationMs: 0,
};
}
return {
healthy: true,
reason: "running",
status: "healthy",
attempts: 1,
durationMs: 0,
};
},
}),
),
(error: unknown) => {
assert.ok(error instanceof RollbackRuntimeError);
assert.equal(error.recoveryMode, "UNCHANGED");
assert.equal(error.recoveryDockerId, "old-container-id");
return true;
},
);
assert.equal(runCount, 1);
assert.deepEqual(removedRefs, [["failed-target-id", "app-rollback"]]);
});
+141
View File
@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
DEVELOPMENT_ENCRYPTION_KEY,
getEncryptionKeyOrThrow,
} from "../../src/server/lib/crypto";
import {
redactDeploymentErrorDetails,
sanitizeDeploymentError,
} from "../../src/server/services/deployment-error.service";
test("production rejects a missing, short, development, or placeholder encryption key", () => {
assert.throws(
() => getEncryptionKeyOrThrow({ NODE_ENV: "production" }),
/must be configured/,
);
assert.throws(
() =>
getEncryptionKeyOrThrow({
NODE_ENV: "production",
ENCRYPTION_KEY: "short",
}),
/at least 32 characters/,
);
assert.throws(
() =>
getEncryptionKeyOrThrow({
NODE_ENV: "production",
ENCRYPTION_KEY: DEVELOPMENT_ENCRYPTION_KEY,
}),
/strong random value/,
);
assert.throws(
() =>
getEncryptionKeyOrThrow({
NODE_ENV: "production",
ENCRYPTION_KEY:
"dev-this-key-is-long-enough-but-still-not-production",
}),
/strong random value/,
);
assert.throws(
() =>
getEncryptionKeyOrThrow({
NODE_ENV: "production",
ENCRYPTION_KEY: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
}),
/strong random value/,
);
});
test("development keeps the legacy fallback while production accepts a strong key", () => {
assert.equal(
getEncryptionKeyOrThrow({ NODE_ENV: "development" }),
DEVELOPMENT_ENCRYPTION_KEY,
);
assert.equal(
getEncryptionKeyOrThrow({
NODE_ENV: "production",
ENCRYPTION_KEY:
"47c2ecffbb4a488eb9a8d1939b54810f1db621bccf1bbf87c87a07de1e6e42d1",
}),
"47c2ecffbb4a488eb9a8d1939b54810f1db621bccf1bbf87c87a07de1e6e42d1",
);
});
test("backend startup validates encryption configuration before connecting to the database", () => {
const source = readFileSync("src/server/index.ts", "utf8");
const validationIndex = source.indexOf("validateEncryptionConfiguration();");
const databaseIndex = source.indexOf("await ensureDatabaseConnection();");
assert.ok(validationIndex >= 0);
assert.ok(databaseIndex > validationIndex);
});
test("deployment error redaction removes credentials, authorization, query secrets, and private keys", () => {
const message = redactDeploymentErrorDetails(
new Error(
[
"Command failed with password=super-secret",
"Authorization: Bearer abc.def.ghi",
"https://deploy-user:[email protected]/repo.git?access_token=query-token",
"-----BEGIN PRIVATE KEY-----",
"private-material",
"-----END PRIVATE KEY-----",
].join("\n"),
),
["super-secret"],
);
assert.doesNotMatch(
message,
/super-secret|abc\.def\.ghi|deploy-user|deploy-pass|query-token|private-material/,
);
assert.match(message, /REDACTED/);
});
test("deployment errors normalize raw command details", () => {
assert.equal(
sanitizeDeploymentError(
new Error(
"Command failed: docker run -e API_TOKEN=secret example/app; stderr: access denied",
),
{ fallback: "Git redeploy failed" },
),
"Git redeploy failed",
);
});
test("deployment and rollback boundaries use the centralized sanitizer before persistence and API responses", () => {
const rollbackService = readFileSync(
"src/server/services/deployment-rollback.service.ts",
"utf8",
);
const containerRoutes = readFileSync(
"src/server/routes/containers.ts",
"utf8",
);
const containerHealth = readFileSync(
"src/server/services/container-health.service.ts",
"utf8",
);
assert.match(
rollbackService,
/const message = sanitizeDeploymentError\(error,[\s\S]*?updateDeployment\(rollback\.id,[\s\S]*?error: message/,
);
assert.match(
containerRoutes,
/"\/:id\/deployments\/:deploymentId\/rollback"[\s\S]*?error: sanitizeDeploymentError\(error/,
);
assert.match(
containerRoutes,
/sanitizeDeploymentError\(ssh\.formatDeploymentErrorMessage\(err\)/,
);
assert.match(containerHealth, /lastReason = sanitizeDeploymentError\(error/);
});
+16
View File
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { resolveDeploymentStrategy } from "../../src/server/services/deployment-strategy";
describe("deployment strategy", () => {
it("uses atomic rename when no published ports are configured", () => {
assert.equal(resolveDeploymentStrategy(""), "ATOMIC_RENAME");
assert.equal(resolveDeploymentStrategy(" "), "ATOMIC_RENAME");
});
it("uses recreate with recovery when published ports are configured", () => {
assert.equal(resolveDeploymentStrategy("8080:80"), "RECREATE_WITH_RECOVERY");
});
});