feat(deployment): add deployment history and rollback support
This commit is contained in:
1 parent
029b25b596
commit
4e9b173edb
36 files changed
+3663
-569
No files matched your search
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+272
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -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>
|
||||
|
||||
+187
-277
@@ -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>
|
||||
);
|
||||
}
|
||||
+60
-117
@@ -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,
|
||||
};
|
||||
}
|
||||
+134
-2
@@ -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}
|
||||
|
||||
+1
-24
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user