feat(s3-storage): add backup metadata, upload verification, restore, and retention
This commit is contained in:
1 parent
dbefb42725
commit
8470a68e78
12 files changed
+1278
-558
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "backups"
|
||||||
|
ADD COLUMN "storageBucket" TEXT,
|
||||||
|
ADD COLUMN "storageKey" TEXT,
|
||||||
|
ADD COLUMN "storageEtag" TEXT,
|
||||||
|
ADD COLUMN "storageDestinationId" TEXT;
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "user_storage_destinations"
|
||||||
|
ADD COLUMN "retentionDays" INTEGER,
|
||||||
|
ADD COLUMN "maxBackupCount" INTEGER,
|
||||||
|
ADD COLUMN "objectPrefix" TEXT;
|
||||||
|
|
||||||
@@ -219,6 +219,9 @@ model UserStorageDestination {
|
|||||||
bucket String
|
bucket String
|
||||||
endpoint String?
|
endpoint String?
|
||||||
additionalFlags String[] @default([])
|
additionalFlags String[] @default([])
|
||||||
|
retentionDays Int?
|
||||||
|
maxBackupCount Int?
|
||||||
|
objectPrefix String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -746,6 +749,10 @@ model Backup {
|
|||||||
target String @default("Local")
|
target String @default("Local")
|
||||||
sizeMb Float?
|
sizeMb Float?
|
||||||
filePath String?
|
filePath String?
|
||||||
|
storageBucket String?
|
||||||
|
storageKey String?
|
||||||
|
storageEtag String?
|
||||||
|
storageDestinationId String?
|
||||||
status BackupStatus @default(PENDING)
|
status BackupStatus @default(PENDING)
|
||||||
error String?
|
error String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -774,3 +781,4 @@ enum BackupStatus {
|
|||||||
COMPLETED
|
COMPLETED
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -48,6 +48,10 @@ function toDestinationInput(
|
|||||||
additionalFlags: overrides?.additionalFlags ?? [
|
additionalFlags: overrides?.additionalFlags ?? [
|
||||||
...destination.additionalFlags,
|
...destination.additionalFlags,
|
||||||
],
|
],
|
||||||
|
retentionDays: overrides?.retentionDays ?? destination.retentionDays,
|
||||||
|
maxBackupCount:
|
||||||
|
overrides?.maxBackupCount ?? destination.maxBackupCount,
|
||||||
|
objectPrefix: overrides?.objectPrefix ?? destination.objectPrefix,
|
||||||
serverId: overrides?.serverId ?? destination.serverId,
|
serverId: overrides?.serverId ?? destination.serverId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -123,6 +127,10 @@ export default function S3StoragePage() {
|
|||||||
!draft.region && "Region",
|
!draft.region && "Region",
|
||||||
!draft.bucket && "Bucket",
|
!draft.bucket && "Bucket",
|
||||||
!draft.name && "Destination Name",
|
!draft.name && "Destination Name",
|
||||||
|
draft.provider !== "awsS3" && !draft.endpoint && "Endpoint",
|
||||||
|
(draft.retentionDays || draft.maxBackupCount) &&
|
||||||
|
!draft.objectPrefix &&
|
||||||
|
"Object Prefix",
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
@@ -175,6 +183,10 @@ export default function S3StoragePage() {
|
|||||||
!draft.region && "Region",
|
!draft.region && "Region",
|
||||||
!draft.bucket && "Bucket",
|
!draft.bucket && "Bucket",
|
||||||
!draft.name && "Destination Name",
|
!draft.name && "Destination Name",
|
||||||
|
draft.provider !== "awsS3" && !draft.endpoint && "Endpoint",
|
||||||
|
(draft.retentionDays || draft.maxBackupCount) &&
|
||||||
|
!draft.objectPrefix &&
|
||||||
|
"Object Prefix",
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
@@ -333,3 +345,4 @@ export default function S3StoragePage() {
|
|||||||
</GuardedPage>
|
</GuardedPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
+22
-15
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* API Client — DOKTAINER
|
* API Client — DOKTAINER
|
||||||
* Centralized HTTP client with auth token injection
|
* Centralized HTTP client with auth token injection
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ function handleUnauthorizedSession(
|
|||||||
throw new Error("Unauthorized - session expired");
|
throw new Error("Unauthorized - session expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Token helpers ────────────────────────────────────────────────────────────
|
// ─── Token helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
return getSensitiveStorageItem(sensitiveStorageKeys.token);
|
return getSensitiveStorageItem(sensitiveStorageKeys.token);
|
||||||
@@ -165,7 +165,7 @@ export function setUser(user: UserInfo): void {
|
|||||||
emitAuthStateChanged();
|
emitAuthStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -457,6 +457,9 @@ export interface S3StorageDestinationRecord {
|
|||||||
bucket: string;
|
bucket: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
additionalFlags: string[];
|
additionalFlags: string[];
|
||||||
|
retentionDays: number | null;
|
||||||
|
maxBackupCount: number | null;
|
||||||
|
objectPrefix: string;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
targetServer: S3StorageTargetServer | null;
|
targetServer: S3StorageTargetServer | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -473,6 +476,9 @@ export interface S3StorageDestinationInput {
|
|||||||
bucket: string;
|
bucket: string;
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
additionalFlags: string[];
|
additionalFlags: string[];
|
||||||
|
retentionDays?: number | null;
|
||||||
|
maxBackupCount?: number | null;
|
||||||
|
objectPrefix?: string;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,7 +628,7 @@ async function readApiErrorMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Core fetch wrapper ───────────────────────────────────────────────────────
|
// ─── Core fetch wrapper ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
@@ -748,7 +754,7 @@ function del<T>(path: string, body?: unknown, config?: { timeoutMs?: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── WebSocket helper ─────────────────────────────────────────────────────────
|
// ─── WebSocket helper ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function createWs(path: string): WebSocket {
|
export function createWs(path: string): WebSocket {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -758,7 +764,7 @@ export function createWs(path: string): WebSocket {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const auth = {
|
export const auth = {
|
||||||
registrationStatus: () =>
|
registrationStatus: () =>
|
||||||
@@ -852,7 +858,7 @@ export const auth = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Users ───────────────────────────────────────────────────────────────────
|
// ─── Users ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type UserRole = "SUPER_ADMIN" | "OPERATOR" | "DEVELOPER" | "VIEWER";
|
export type UserRole = "SUPER_ADMIN" | "OPERATOR" | "DEVELOPER" | "VIEWER";
|
||||||
|
|
||||||
@@ -1238,7 +1244,7 @@ export const gitProvidersApi = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Servers ──────────────────────────────────────────────────────────────────
|
// ─── Servers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Server {
|
export interface Server {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1530,7 +1536,7 @@ export const servers = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Containers ───────────────────────────────────────────────────────────────
|
// ─── Containers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Container {
|
export interface Container {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1997,7 +2003,7 @@ export const containers = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Metrics ──────────────────────────────────────────────────────────────────
|
// ─── Metrics ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface DashboardOverview {
|
export interface DashboardOverview {
|
||||||
servers: { total: number; online: number };
|
servers: { total: number; online: number };
|
||||||
@@ -2053,7 +2059,7 @@ export const metrics = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Domains ──────────────────────────────────────────────────────────────────
|
// ─── Domains ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Domain {
|
export interface Domain {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -2444,7 +2450,7 @@ export const security = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Apps ────────────────────────────────────────────────────────────────────
|
// ─── Apps ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AppTemplate {
|
export interface AppTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -2643,7 +2649,7 @@ export const apps = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Backups ──────────────────────────────────────────────────────────────────
|
// ─── Backups ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Backup {
|
export interface Backup {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -2744,7 +2750,7 @@ export const backups = {
|
|||||||
delete: (id: string) => del<{ success: boolean }>(`/backups/${id}`),
|
delete: (id: string) => del<{ success: boolean }>(`/backups/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Logs ─────────────────────────────────────────────────────────────────────
|
// ─── Logs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const logs = {
|
export const logs = {
|
||||||
list: (params?: {
|
list: (params?: {
|
||||||
@@ -2769,7 +2775,7 @@ export const logs = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Terminal ─────────────────────────────────────────────────────────────────
|
// ─── Terminal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const terminal = {
|
export const terminal = {
|
||||||
sessions: () =>
|
sessions: () =>
|
||||||
@@ -2841,3 +2847,4 @@ export const apiClient = {
|
|||||||
terminal,
|
terminal,
|
||||||
};
|
};
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|
||||||
+10
-7
@@ -26,6 +26,7 @@ import { projectsRoutes } from "./routes/projects";
|
|||||||
import { gitProviderRoutes } from "./routes/git-providers";
|
import { gitProviderRoutes } from "./routes/git-providers";
|
||||||
import { storageDestinationRoutes } from "./routes/storage-destinations";
|
import { storageDestinationRoutes } from "./routes/storage-destinations";
|
||||||
import { commitHistoryRoutes } from "./routes/commit-history";
|
import { commitHistoryRoutes } from "./routes/commit-history";
|
||||||
|
import { startS3StorageRetentionScheduler } from "./services/s3-storage-retention.service";
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || "4000");
|
const PORT = parseInt(process.env.PORT || "4000");
|
||||||
const HOST = process.env.HOST || "0.0.0.0";
|
const HOST = process.env.HOST || "0.0.0.0";
|
||||||
@@ -111,7 +112,7 @@ async function start() {
|
|||||||
await ensureDatabaseConnection();
|
await ensureDatabaseConnection();
|
||||||
setDefaultSecurityHeaders();
|
setDefaultSecurityHeaders();
|
||||||
|
|
||||||
// ── Plugins ──────────────────────────────────────────
|
// ── Plugins ──────────────────────────────────────────
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: getCorsOrigins(),
|
origin: getCorsOrigins(),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -142,7 +143,7 @@ async function start() {
|
|||||||
|
|
||||||
await app.register(websocket);
|
await app.register(websocket);
|
||||||
|
|
||||||
// ── Health check ──────────────────────────────────────
|
// ── Health check ──────────────────────────────────────
|
||||||
app.get("/health", { config: { rateLimit: false } }, async () => ({
|
app.get("/health", { config: { rateLimit: false } }, async () => ({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
@@ -150,7 +151,7 @@ async function start() {
|
|||||||
version: process.env.NEXT_PUBLIC_VERSION || "unknown",
|
version: process.env.NEXT_PUBLIC_VERSION || "unknown",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── API Routes ────────────────────────────────────────
|
// ── API Routes ────────────────────────────────────────
|
||||||
const API_PREFIX = "/api/v1";
|
const API_PREFIX = "/api/v1";
|
||||||
|
|
||||||
await app.register(authRoutes, { prefix: `${API_PREFIX}/auth` });
|
await app.register(authRoutes, { prefix: `${API_PREFIX}/auth` });
|
||||||
@@ -182,7 +183,7 @@ async function start() {
|
|||||||
prefix: `${API_PREFIX}/notifications`,
|
prefix: `${API_PREFIX}/notifications`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Error handler ─────────────────────────────────────
|
// ── Error handler ─────────────────────────────────────
|
||||||
app.setErrorHandler((error: FastifyError, _req, reply) => {
|
app.setErrorHandler((error: FastifyError, _req, reply) => {
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
const code = error.statusCode || 500;
|
const code = error.statusCode || 500;
|
||||||
@@ -193,13 +194,14 @@ async function start() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────
|
||||||
try {
|
try {
|
||||||
await app.listen({ port: PORT, host: HOST });
|
await app.listen({ port: PORT, host: HOST });
|
||||||
|
startS3StorageRetentionScheduler();
|
||||||
console.log(
|
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) {
|
} catch (err) {
|
||||||
const error = err as NodeJS.ErrnoException;
|
const error = err as NodeJS.ErrnoException;
|
||||||
if (error.code === "EADDRINUSE") {
|
if (error.code === "EADDRINUSE") {
|
||||||
@@ -225,3 +227,4 @@ if (require.main === module) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { app, start };
|
export { app, start };
|
||||||
|
|
||||||
+254
-73
@@ -1,19 +1,31 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import {
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
type HeadObjectCommandOutput,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
|
import { mkdtemp, rm, writeFile } from "fs/promises";
|
||||||
|
import { tmpdir } from "os";
|
||||||
import { posix as pathPosix } from "path";
|
import { posix as pathPosix } from "path";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { gunzipSync } from "zlib";
|
import { gunzipSync } from "zlib";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
import { decrypt } from "../lib/crypto";
|
|
||||||
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
||||||
import { auditLog } from "../services/audit.service";
|
import { auditLog } from "../services/audit.service";
|
||||||
import { dispatchRuntimeNotification } from "../services/notification.service";
|
import { dispatchRuntimeNotification } from "../services/notification.service";
|
||||||
|
import {
|
||||||
|
buildS3ObjectKey,
|
||||||
|
createS3Client,
|
||||||
|
} from "../services/s3-storage.service";
|
||||||
import * as ssh from "../services/ssh.service";
|
import * as ssh from "../services/ssh.service";
|
||||||
|
|
||||||
const backupsAccess = [authenticate, requireApiKeyPermission("write:backups")];
|
const backupsAccess = [authenticate, requireApiKeyPermission("write:backups")];
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse "du -sb <path>" output and return size in MB
|
* Parse "du -sb <path>" output and return size in MB
|
||||||
@@ -47,6 +59,7 @@ const DATABASE_IMAGE_HINTS = [
|
|||||||
"valkey",
|
"valkey",
|
||||||
];
|
];
|
||||||
const DOCKER_VOLUMES_BASE_PATH = "/var/lib/docker/volumes";
|
const DOCKER_VOLUMES_BASE_PATH = "/var/lib/docker/volumes";
|
||||||
|
const BACKUP_DIR = "/var/backups/vps-panel";
|
||||||
const DOCKER_VOLUME_LIST_TIMEOUT_MS = 10_000;
|
const DOCKER_VOLUME_LIST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
function dockerVolumeListTimeout() {
|
function dockerVolumeListTimeout() {
|
||||||
@@ -56,6 +69,10 @@ function dockerVolumeListTimeout() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
type DatabaseEngine = "POSTGRESQL" | "MYSQL" | "MARIADB" | "MONGODB" | "REDIS";
|
type DatabaseEngine = "POSTGRESQL" | "MYSQL" | "MARIADB" | "MONGODB" | "REDIS";
|
||||||
|
|
||||||
type DatabaseBackupPlan = {
|
type DatabaseBackupPlan = {
|
||||||
@@ -77,6 +94,7 @@ type StorageDestinationRecord = {
|
|||||||
bucket: string;
|
bucket: string;
|
||||||
endpoint: string | null;
|
endpoint: string | null;
|
||||||
additionalFlags: string[];
|
additionalFlags: string[];
|
||||||
|
objectPrefix?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const prismaStorage = prisma as typeof prisma & {
|
const prismaStorage = prisma as typeof prisma & {
|
||||||
@@ -168,26 +186,6 @@ async function readRemoteFileBase64(
|
|||||||
return Buffer.from(payload, "base64");
|
return Buffer.from(payload, "base64");
|
||||||
}
|
}
|
||||||
|
|
||||||
function createS3Client(destination: StorageDestinationRecord) {
|
|
||||||
if (!destination.secretAccessKeyEnc) {
|
|
||||||
throw new Error("Storage destination secret key is missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new S3Client({
|
|
||||||
region: destination.region ?? "auto",
|
|
||||||
endpoint: destination.endpoint || undefined,
|
|
||||||
forcePathStyle:
|
|
||||||
Boolean(destination.endpoint) ||
|
|
||||||
destination.additionalFlags.some((flag) =>
|
|
||||||
flag.toLowerCase().includes("forcepathstyle=true"),
|
|
||||||
),
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: destination.accessKeyId,
|
|
||||||
secretAccessKey: decrypt(destination.secretAccessKeyEnc),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadBackupToS3(args: {
|
async function uploadBackupToS3(args: {
|
||||||
server: Parameters<typeof ssh.exec>[0];
|
server: Parameters<typeof ssh.exec>[0];
|
||||||
filePath: string;
|
filePath: string;
|
||||||
@@ -196,22 +194,119 @@ async function uploadBackupToS3(args: {
|
|||||||
}) {
|
}) {
|
||||||
const body = await readRemoteFileBase64(args.server, args.filePath);
|
const body = await readRemoteFileBase64(args.server, args.filePath);
|
||||||
const client = createS3Client(args.destination);
|
const client = createS3Client(args.destination);
|
||||||
|
const objectKey = buildS3ObjectKey(args.destination, args.filename);
|
||||||
|
|
||||||
await client.send(
|
const result = await client.send(
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
Bucket: args.destination.bucket,
|
Bucket: args.destination.bucket,
|
||||||
Key: args.filename,
|
Key: objectKey,
|
||||||
Body: body,
|
Body: body,
|
||||||
ContentType: "application/gzip",
|
ContentType: "application/gzip",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const verification = await verifyUploadedS3Object({
|
||||||
|
client,
|
||||||
|
bucket: args.destination.bucket,
|
||||||
|
key: objectKey,
|
||||||
|
expectedSize: body.length,
|
||||||
|
uploadedEtag: result.ETag,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bucket: args.destination.bucket,
|
bucket: args.destination.bucket,
|
||||||
key: args.filename,
|
key: objectKey,
|
||||||
|
etag: verification.etag,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyUploadedS3Object(args: {
|
||||||
|
client: S3Client;
|
||||||
|
bucket: string;
|
||||||
|
key: string;
|
||||||
|
expectedSize: number;
|
||||||
|
uploadedEtag?: string;
|
||||||
|
}) {
|
||||||
|
let metadata: HeadObjectCommandOutput;
|
||||||
|
|
||||||
|
try {
|
||||||
|
metadata = await args.client.send(
|
||||||
|
new HeadObjectCommand({ Bucket: args.bucket, Key: args.key }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`S3 object verification failed for ${args.bucket}/${args.key}: ${getErrorMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof metadata.ContentLength !== "number") {
|
||||||
|
throw new Error(
|
||||||
|
`S3 object verification failed for ${args.bucket}/${args.key}: object size is unavailable`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.ContentLength !== args.expectedSize) {
|
||||||
|
throw new Error(
|
||||||
|
`S3 object verification failed for ${args.bucket}/${args.key}: expected ${args.expectedSize} bytes, received ${metadata.ContentLength} bytes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
etag: metadata.ETag ?? args.uploadedEtag ?? null,
|
||||||
|
size: metadata.ContentLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBackupFromS3(
|
||||||
|
destination: StorageDestinationRecord,
|
||||||
|
bucket: string,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
const response = await createS3Client(destination).send(
|
||||||
|
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.Body) {
|
||||||
|
throw new Error("S3 object was returned without a body");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(await response.Body.transformToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeRemoteFileFromBuffer(
|
||||||
|
server: Parameters<typeof ssh.exec>[0],
|
||||||
|
filePath: string,
|
||||||
|
content: Buffer,
|
||||||
|
) {
|
||||||
|
const tempDirectory = await mkdtemp(`${tmpdir()}/doktainer-restore-`);
|
||||||
|
const localPath = `${tempDirectory}/${randomUUID()}.backup`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(localPath, content);
|
||||||
|
await ssh.execStrict(
|
||||||
|
server,
|
||||||
|
`mkdir -p ${escapeShellArg(pathPosix.dirname(filePath))}`,
|
||||||
|
);
|
||||||
|
await ssh.withIsolatedConnection(server, (connection) =>
|
||||||
|
connection.putFile(localPath, filePath),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRemoteBackupFile(
|
||||||
|
server: Parameters<typeof ssh.exec>[0],
|
||||||
|
filePath: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await ssh.execStrict(server, `rm -f ${escapeShellArg(filePath)}`);
|
||||||
|
} catch {
|
||||||
|
// The S3 copy is already durable; cleanup failure must not turn a
|
||||||
|
// successful upload into a failed backup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getBackupBaseName(name: string) {
|
function getBackupBaseName(name: string) {
|
||||||
return name.replace(/[^a-zA-Z0-9_-]/g, "_") || "backup";
|
return name.replace(/[^a-zA-Z0-9_-]/g, "_") || "backup";
|
||||||
}
|
}
|
||||||
@@ -455,7 +550,7 @@ function buildDatabaseBackupCommand(args: {
|
|||||||
].join(" && ");
|
].join(" && ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Routes ──────────────────────────────────────────────────────────────────
|
// ─── Routes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function backupsRoutes(app: FastifyInstance) {
|
export async function backupsRoutes(app: FastifyInstance) {
|
||||||
app.get("/options", { preHandler: backupsAccess }, async (req, reply) => {
|
app.get("/options", { preHandler: backupsAccess }, async (req, reply) => {
|
||||||
@@ -545,7 +640,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /backups — list backups (optionally filter by serverId)
|
// GET /backups — list backups (optionally filter by serverId)
|
||||||
app.get("/", { preHandler: backupsAccess }, async (req, reply) => {
|
app.get("/", { preHandler: backupsAccess }, async (req, reply) => {
|
||||||
const { serverId } = req.query as { serverId?: string };
|
const { serverId } = req.query as { serverId?: string };
|
||||||
|
|
||||||
@@ -605,20 +700,47 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
.send({ success: false, error: "Backup not found" });
|
.send({ success: false, error: "Backup not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!backup.filePath) {
|
|
||||||
return reply
|
|
||||||
.status(400)
|
|
||||||
.send({ success: false, error: "Backup has no file path recorded" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await readRemoteFileBase64(
|
let content: Buffer;
|
||||||
backup.server,
|
|
||||||
backup.filePath,
|
if (backup.storageBucket && backup.storageKey) {
|
||||||
);
|
const destination = backup.storageDestinationId
|
||||||
|
? await prismaStorage.userStorageDestination.findFirst({
|
||||||
|
where: {
|
||||||
|
id: backup.storageDestinationId,
|
||||||
|
userId: req.userId!,
|
||||||
|
organizationId: req.organizationId!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!destination) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
success: false,
|
||||||
|
error: "The S3 destination for this backup is no longer available",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
content = await downloadBackupFromS3(
|
||||||
|
destination,
|
||||||
|
backup.storageBucket,
|
||||||
|
backup.storageKey,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (!backup.filePath) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
success: false,
|
||||||
|
error: "Backup has no local file or S3 object recorded",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
content = await readRemoteFileBase64(backup.server, backup.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
let payload = content;
|
let payload = content;
|
||||||
let fileName =
|
let fileName =
|
||||||
backup.filePath.split("/").pop() ||
|
backup.storageKey?.split("/").pop() ||
|
||||||
|
backup.filePath?.split("/").pop() ||
|
||||||
getDownloadArchiveName(backup.name, ".bak");
|
getDownloadArchiveName(backup.name, ".bak");
|
||||||
let contentType = "application/octet-stream";
|
let contentType = "application/octet-stream";
|
||||||
|
|
||||||
@@ -689,7 +811,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /backups — trigger a new backup
|
// POST /backups — trigger a new backup
|
||||||
app.post("/", { preHandler: backupsAccess }, async (req, reply) => {
|
app.post("/", { preHandler: backupsAccess }, async (req, reply) => {
|
||||||
const body = z
|
const body = z
|
||||||
.object({
|
.object({
|
||||||
@@ -790,6 +912,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
? `S3: ${storageDestination.name}`
|
? `S3: ${storageDestination.name}`
|
||||||
: body.data.target,
|
: body.data.target,
|
||||||
filePath,
|
filePath,
|
||||||
|
storageDestinationId: storageDestination?.id ?? null,
|
||||||
status: "RUNNING",
|
status: "RUNNING",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -858,7 +981,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
case "FULL":
|
case "FULL":
|
||||||
default: {
|
default: {
|
||||||
// Backup common paths: /etc, /var/www, /home, /opt — skip /proc /sys /dev
|
// Backup common paths: /etc, /var/www, /home, /opt — skip /proc /sys /dev
|
||||||
backupCmd = `tar czf ${filePath} --exclude=/proc --exclude=/sys --exclude=/dev --exclude=/run --exclude=/tmp --one-file-system /etc /var/www /home /opt 2>/dev/null || true`;
|
backupCmd = `tar czf ${filePath} --exclude=/proc --exclude=/sys --exclude=/dev --exclude=/run --exclude=/tmp --one-file-system /etc /var/www /home /opt 2>/dev/null || true`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -866,21 +989,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
await ssh.execStrict(server, backupCmd);
|
await ssh.execStrict(server, backupCmd);
|
||||||
|
|
||||||
let uploadedObject: {
|
// Capture the local size before an S3-targeted backup is cleaned up.
|
||||||
bucket: string;
|
|
||||||
key: string;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
if (storageDestination) {
|
|
||||||
uploadedObject = await uploadBackupToS3({
|
|
||||||
server,
|
|
||||||
filePath,
|
|
||||||
filename,
|
|
||||||
destination: storageDestination,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file size
|
|
||||||
const sizeOut = await ssh.execStrict(
|
const sizeOut = await ssh.execStrict(
|
||||||
server,
|
server,
|
||||||
`test -s ${escapeShellArg(filePath)} && wc -c < ${escapeShellArg(filePath)}`,
|
`test -s ${escapeShellArg(filePath)} && wc -c < ${escapeShellArg(filePath)}`,
|
||||||
@@ -891,9 +1000,31 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
throw new Error("Backup file was created but is empty");
|
throw new Error("Backup file was created but is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let uploadedObject: {
|
||||||
|
bucket: string;
|
||||||
|
key: string;
|
||||||
|
etag: string | null;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
if (storageDestination) {
|
||||||
|
uploadedObject = await uploadBackupToS3({
|
||||||
|
server,
|
||||||
|
filePath,
|
||||||
|
filename,
|
||||||
|
destination: storageDestination,
|
||||||
|
});
|
||||||
|
await removeRemoteBackupFile(server, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.backup.update({
|
await prisma.backup.update({
|
||||||
where: { id: backup.id },
|
where: { id: backup.id },
|
||||||
data: { status: "COMPLETED", sizeMb },
|
data: {
|
||||||
|
status: "COMPLETED",
|
||||||
|
sizeMb,
|
||||||
|
storageBucket: uploadedObject?.bucket ?? null,
|
||||||
|
storageKey: uploadedObject?.key ?? null,
|
||||||
|
storageEtag: uploadedObject?.etag ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await auditLog({
|
await auditLog({
|
||||||
@@ -940,13 +1071,15 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
filePath,
|
filePath,
|
||||||
storageBucket: uploadedObject?.bucket,
|
storageBucket: uploadedObject?.bucket,
|
||||||
storageKey: uploadedObject?.key,
|
storageKey: uploadedObject?.key,
|
||||||
|
storageEtag: uploadedObject?.etag,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = getErrorMessage(err);
|
||||||
await prisma.backup.update({
|
await prisma.backup.update({
|
||||||
where: { id: backup.id },
|
where: { id: backup.id },
|
||||||
data: { status: "FAILED", error: err.message },
|
data: { status: "FAILED", error: errorMessage },
|
||||||
});
|
});
|
||||||
|
|
||||||
await auditLog({
|
await auditLog({
|
||||||
@@ -956,7 +1089,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
action: "BACKUP_FAILED",
|
action: "BACKUP_FAILED",
|
||||||
category: "SYSTEM",
|
category: "SYSTEM",
|
||||||
level: "ERROR",
|
level: "ERROR",
|
||||||
message: `Backup "${body.data.name}" failed on "${server.name}": ${err.message}`,
|
message: `Backup "${body.data.name}" failed on "${server.name}": ${errorMessage}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.organizationId) {
|
if (req.organizationId) {
|
||||||
@@ -990,7 +1123,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
serverName: server.name,
|
serverName: server.name,
|
||||||
serverIp: server.ip,
|
serverIp: server.ip,
|
||||||
filePath,
|
filePath,
|
||||||
error: err.message,
|
error: errorMessage,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1004,7 +1137,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /backups/:id/restore — trigger restore from backup file
|
// POST /backups/:id/restore — trigger restore from backup file
|
||||||
app.post(
|
app.post(
|
||||||
"/:id/restore",
|
"/:id/restore",
|
||||||
{ preHandler: backupsAccess },
|
{ preHandler: backupsAccess },
|
||||||
@@ -1025,30 +1158,72 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
error: "Only completed backups can be restored",
|
error: "Only completed backups can be restored",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!backup.filePath) {
|
if (!backup.filePath && !(backup.storageBucket && backup.storageKey)) {
|
||||||
return reply
|
return reply
|
||||||
.status(400)
|
.status(400)
|
||||||
.send({ success: false, error: "Backup has no file path recorded" });
|
.send({
|
||||||
|
success: false,
|
||||||
|
error: "Backup has no local file or S3 object recorded",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore in background
|
// Restore in background
|
||||||
setImmediate(async () => {
|
setImmediate(async () => {
|
||||||
|
let hydratedFilePath: string | null = null;
|
||||||
try {
|
try {
|
||||||
|
let restoreFilePath = backup.filePath;
|
||||||
|
|
||||||
|
if (backup.storageBucket && backup.storageKey) {
|
||||||
|
const destination = backup.storageDestinationId
|
||||||
|
? await prismaStorage.userStorageDestination.findFirst({
|
||||||
|
where: {
|
||||||
|
id: backup.storageDestinationId,
|
||||||
|
userId: req.userId!,
|
||||||
|
organizationId: req.organizationId!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!destination) {
|
||||||
|
throw new Error(
|
||||||
|
"The S3 destination for this backup is no longer available",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreFilePath = `${BACKUP_DIR}/.doktainer-restore-${backup.id}.${
|
||||||
|
backup.type === "DATABASE" ? "sql.gz" : "tar.gz"
|
||||||
|
}`;
|
||||||
|
await writeRemoteFileFromBuffer(
|
||||||
|
backup.server,
|
||||||
|
restoreFilePath,
|
||||||
|
await downloadBackupFromS3(
|
||||||
|
destination,
|
||||||
|
backup.storageBucket,
|
||||||
|
backup.storageKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
hydratedFilePath = restoreFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!restoreFilePath) {
|
||||||
|
throw new Error("Backup restore path is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
let restoreCmd: string;
|
let restoreCmd: string;
|
||||||
|
|
||||||
switch (backup.type) {
|
switch (backup.type) {
|
||||||
case "FULL":
|
case "FULL":
|
||||||
case "VOLUME":
|
case "VOLUME":
|
||||||
restoreCmd = `tar xzf ${backup.filePath} -C / 2>/dev/null || true`;
|
restoreCmd = `tar xzf ${escapeShellArg(restoreFilePath)} -C / 2>/dev/null`;
|
||||||
break;
|
break;
|
||||||
case "DATABASE":
|
case "DATABASE":
|
||||||
default:
|
default:
|
||||||
// Pipe unzipped dump back to psql — DBA should supervise this
|
// Pipe unzipped dump back to psql — DBA should supervise this
|
||||||
restoreCmd = `zcat ${backup.filePath} | psql -U postgres 2>/dev/null || zcat ${backup.filePath} | mysql -u root 2>/dev/null || true`;
|
restoreCmd = `zcat ${escapeShellArg(restoreFilePath)} | psql -U postgres 2>/dev/null || zcat ${escapeShellArg(restoreFilePath)} | mysql -u root 2>/dev/null`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ssh.exec(backup.server, restoreCmd);
|
await ssh.execStrict(backup.server, restoreCmd);
|
||||||
|
|
||||||
await auditLog({
|
await auditLog({
|
||||||
userId: req.userId,
|
userId: req.userId,
|
||||||
@@ -1059,7 +1234,8 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
level: "WARNING",
|
level: "WARNING",
|
||||||
message: `Backup "${backup.name}" restored on "${backup.server.name}"`,
|
message: `Backup "${backup.name}" restored on "${backup.server.name}"`,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = getErrorMessage(err);
|
||||||
await auditLog({
|
await auditLog({
|
||||||
userId: req.userId,
|
userId: req.userId,
|
||||||
organizationId: req.organizationId,
|
organizationId: req.organizationId,
|
||||||
@@ -1067,8 +1243,12 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
action: "BACKUP_RESTORE_FAILED",
|
action: "BACKUP_RESTORE_FAILED",
|
||||||
category: "SYSTEM",
|
category: "SYSTEM",
|
||||||
level: "ERROR",
|
level: "ERROR",
|
||||||
message: `Backup restore failed for "${backup.name}" on "${backup.server.name}": ${err.message}`,
|
message: `Backup restore failed for "${backup.name}" on "${backup.server.name}": ${errorMessage}`,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
if (hydratedFilePath) {
|
||||||
|
await removeRemoteBackupFile(backup.server, hydratedFilePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1076,7 +1256,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /backups/:id — delete backup record and file on server
|
// DELETE /backups/:id — delete backup record and file on server
|
||||||
app.delete("/:id", { preHandler: backupsAccess }, async (req, reply) => {
|
app.delete("/:id", { preHandler: backupsAccess }, async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|
||||||
@@ -1094,7 +1274,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
try {
|
try {
|
||||||
await ssh.exec(backup.server, `rm -f ${backup.filePath}`);
|
await ssh.exec(backup.server, `rm -f ${backup.filePath}`);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore SSH errors — still delete the DB record
|
// Ignore SSH errors — still delete the DB record
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1113,7 +1293,7 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
return reply.send({ success: true, message: "Backup deleted" });
|
return reply.send({ success: true, message: "Backup deleted" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /backups/stats — aggregate stats for dashboard
|
// GET /backups/stats — aggregate stats for dashboard
|
||||||
app.get("/stats", { preHandler: backupsAccess }, async (req, reply) => {
|
app.get("/stats", { preHandler: backupsAccess }, async (req, reply) => {
|
||||||
const [total, completed, running, failed] = await Promise.all([
|
const [total, completed, running, failed] = await Promise.all([
|
||||||
prisma.backup.count({
|
prisma.backup.count({
|
||||||
@@ -1145,3 +1325,4 @@ export async function backupsRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,9 @@ const StorageDestinationSchema = z.object({
|
|||||||
bucket: z.string().trim().min(1).max(128),
|
bucket: z.string().trim().min(1).max(128),
|
||||||
endpoint: z.string().trim().url().max(2048).optional().or(z.literal("")),
|
endpoint: z.string().trim().url().max(2048).optional().or(z.literal("")),
|
||||||
additionalFlags: z.array(z.string().trim().min(1).max(255)).max(12),
|
additionalFlags: z.array(z.string().trim().min(1).max(255)).max(12),
|
||||||
|
retentionDays: z.number().int().min(1).max(3650).nullable().optional(),
|
||||||
|
maxBackupCount: z.number().int().min(1).max(10000).nullable().optional(),
|
||||||
|
objectPrefix: z.string().trim().max(255).optional().or(z.literal("")),
|
||||||
serverId: z.string().trim().max(64).optional().or(z.literal("")),
|
serverId: z.string().trim().max(64).optional().or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,6 +81,9 @@ type StorageDestinationRecord = {
|
|||||||
bucket: string;
|
bucket: string;
|
||||||
endpoint: string | null;
|
endpoint: string | null;
|
||||||
additionalFlags: string[];
|
additionalFlags: string[];
|
||||||
|
retentionDays: number | null;
|
||||||
|
maxBackupCount: number | null;
|
||||||
|
objectPrefix: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
server?: {
|
server?: {
|
||||||
@@ -114,6 +120,9 @@ function serializeDestination(destination: StorageDestinationRecord) {
|
|||||||
bucket: destination.bucket,
|
bucket: destination.bucket,
|
||||||
endpoint: destination.endpoint ?? "",
|
endpoint: destination.endpoint ?? "",
|
||||||
additionalFlags: destination.additionalFlags,
|
additionalFlags: destination.additionalFlags,
|
||||||
|
retentionDays: destination.retentionDays,
|
||||||
|
maxBackupCount: destination.maxBackupCount,
|
||||||
|
objectPrefix: destination.objectPrefix ?? "",
|
||||||
serverId: destination.serverId,
|
serverId: destination.serverId,
|
||||||
targetServer: destination.server ?? null,
|
targetServer: destination.server ?? null,
|
||||||
createdAt: destination.createdAt.toISOString(),
|
createdAt: destination.createdAt.toISOString(),
|
||||||
@@ -133,6 +142,10 @@ function validateDestinationInput(
|
|||||||
return "Secret access key is required";
|
return "Secret access key is required";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((input.retentionDays || input.maxBackupCount) && !toTrimmedValue(input.objectPrefix)) {
|
||||||
|
return "Object prefix is required when retention or maximum backup count is enabled";
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +185,9 @@ function buildDestinationWriteData(
|
|||||||
region: toTrimmedValue(input.region),
|
region: toTrimmedValue(input.region),
|
||||||
bucket: toTrimmedValue(input.bucket),
|
bucket: toTrimmedValue(input.bucket),
|
||||||
endpoint: toTrimmedValue(input.endpoint) || null,
|
endpoint: toTrimmedValue(input.endpoint) || null,
|
||||||
|
retentionDays: input.retentionDays ?? null,
|
||||||
|
maxBackupCount: input.maxBackupCount ?? null,
|
||||||
|
objectPrefix: toTrimmedValue(input.objectPrefix) || null,
|
||||||
additionalFlags: [
|
additionalFlags: [
|
||||||
...new Set(
|
...new Set(
|
||||||
input.additionalFlags.map((entry) => entry.trim()).filter(Boolean),
|
input.additionalFlags.map((entry) => entry.trim()).filter(Boolean),
|
||||||
@@ -201,6 +217,9 @@ async function getDestinationById(
|
|||||||
bucket: true,
|
bucket: true,
|
||||||
endpoint: true,
|
endpoint: true,
|
||||||
additionalFlags: true,
|
additionalFlags: true,
|
||||||
|
retentionDays: true,
|
||||||
|
maxBackupCount: true,
|
||||||
|
objectPrefix: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
server: {
|
server: {
|
||||||
@@ -244,6 +263,9 @@ export async function storageDestinationRoutes(app: FastifyInstance) {
|
|||||||
bucket: true,
|
bucket: true,
|
||||||
endpoint: true,
|
endpoint: true,
|
||||||
additionalFlags: true,
|
additionalFlags: true,
|
||||||
|
retentionDays: true,
|
||||||
|
maxBackupCount: true,
|
||||||
|
objectPrefix: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
server: {
|
server: {
|
||||||
@@ -322,6 +344,9 @@ export async function storageDestinationRoutes(app: FastifyInstance) {
|
|||||||
bucket: true,
|
bucket: true,
|
||||||
endpoint: true,
|
endpoint: true,
|
||||||
additionalFlags: true,
|
additionalFlags: true,
|
||||||
|
retentionDays: true,
|
||||||
|
maxBackupCount: true,
|
||||||
|
objectPrefix: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
server: {
|
server: {
|
||||||
@@ -442,6 +467,9 @@ export async function storageDestinationRoutes(app: FastifyInstance) {
|
|||||||
bucket: true,
|
bucket: true,
|
||||||
endpoint: true,
|
endpoint: true,
|
||||||
additionalFlags: true,
|
additionalFlags: true,
|
||||||
|
retentionDays: true,
|
||||||
|
maxBackupCount: true,
|
||||||
|
objectPrefix: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
server: {
|
server: {
|
||||||
@@ -616,3 +644,4 @@ export async function storageDestinationRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import prisma from "../lib/prisma";
|
||||||
|
import { deleteS3Objects, listS3Objects } from "./s3-storage.service";
|
||||||
|
|
||||||
|
const RETENTION_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
type RetentionDestination = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
organizationId: string;
|
||||||
|
name: string;
|
||||||
|
bucket: string;
|
||||||
|
endpoint: string | null;
|
||||||
|
region: string | null;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKeyEnc: string | null;
|
||||||
|
additionalFlags: string[];
|
||||||
|
retentionDays: number | null;
|
||||||
|
maxBackupCount: number | null;
|
||||||
|
objectPrefix: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prismaStorage = prisma as typeof prisma & {
|
||||||
|
userStorageDestination: {
|
||||||
|
findMany: (args: unknown) => Promise<RetentionDestination[]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let cleanupInProgress = false;
|
||||||
|
|
||||||
|
export function getExpiredObjectKeys(
|
||||||
|
objects: Array<{ key: string; lastModified: Date | null; size: number }>,
|
||||||
|
destination: RetentionDestination,
|
||||||
|
now = Date.now(),
|
||||||
|
) {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
if (destination.retentionDays) {
|
||||||
|
const cutoff = now - destination.retentionDays * 24 * 60 * 60 * 1000;
|
||||||
|
for (const object of objects) {
|
||||||
|
if (object.lastModified && object.lastModified.getTime() < cutoff) {
|
||||||
|
keys.add(object.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.maxBackupCount) {
|
||||||
|
const remaining = objects
|
||||||
|
.filter((object) => !keys.has(object.key))
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
(right.lastModified?.getTime() ?? 0) -
|
||||||
|
(left.lastModified?.getTime() ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const object of remaining.slice(destination.maxBackupCount)) {
|
||||||
|
keys.add(object.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupDestination(destination: RetentionDestination) {
|
||||||
|
if (
|
||||||
|
!destination.objectPrefix ||
|
||||||
|
(!destination.retentionDays && !destination.maxBackupCount)
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objects = await listS3Objects(destination);
|
||||||
|
const keysToDelete = getExpiredObjectKeys(objects, destination);
|
||||||
|
if (keysToDelete.length === 0) return 0;
|
||||||
|
|
||||||
|
await deleteS3Objects(destination, keysToDelete);
|
||||||
|
|
||||||
|
await prisma.backup.updateMany({
|
||||||
|
where: {
|
||||||
|
storageDestinationId: destination.id,
|
||||||
|
storageKey: { in: keysToDelete },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
storageBucket: null,
|
||||||
|
storageKey: null,
|
||||||
|
storageEtag: null,
|
||||||
|
error: "S3 object removed by retention policy",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return keysToDelete.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runS3StorageRetentionCleanup() {
|
||||||
|
if (cleanupInProgress) return { skipped: true, deletedObjects: 0 };
|
||||||
|
cleanupInProgress = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const destinations = await prismaStorage.userStorageDestination.findMany({
|
||||||
|
where: {
|
||||||
|
enabled: true,
|
||||||
|
objectPrefix: { not: null },
|
||||||
|
OR: [
|
||||||
|
{ retentionDays: { not: null } },
|
||||||
|
{ maxBackupCount: { not: null } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let deletedObjects = 0;
|
||||||
|
for (const destination of destinations) {
|
||||||
|
try {
|
||||||
|
deletedObjects += await cleanupDestination(destination);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[S3 retention] Failed for destination ${destination.id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { skipped: false, deletedObjects };
|
||||||
|
} finally {
|
||||||
|
cleanupInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startS3StorageRetentionScheduler() {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void runS3StorageRetentionCleanup();
|
||||||
|
}, RETENTION_INTERVAL_MS);
|
||||||
|
|
||||||
|
timer.unref?.();
|
||||||
|
void runS3StorageRetentionCleanup();
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
DeleteObjectsCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
S3Client,
|
||||||
|
type ObjectIdentifier,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
|
import { decrypt } from "../lib/crypto";
|
||||||
|
|
||||||
|
export interface S3StorageClientDestination {
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKeyEnc: string | null;
|
||||||
|
region: string | null;
|
||||||
|
endpoint: string | null;
|
||||||
|
additionalFlags: string[];
|
||||||
|
bucket: string;
|
||||||
|
objectPrefix?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createS3Client(destination: S3StorageClientDestination) {
|
||||||
|
if (!destination.secretAccessKeyEnc) {
|
||||||
|
throw new Error("Storage destination secret key is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new S3Client({
|
||||||
|
region: destination.region ?? "auto",
|
||||||
|
endpoint: destination.endpoint || undefined,
|
||||||
|
forcePathStyle:
|
||||||
|
Boolean(destination.endpoint) ||
|
||||||
|
destination.additionalFlags.some((flag) =>
|
||||||
|
flag.toLowerCase().includes("forcepathstyle=true"),
|
||||||
|
),
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: destination.accessKeyId,
|
||||||
|
secretAccessKey: decrypt(destination.secretAccessKeyEnc),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeObjectPrefix(prefix: string | null | undefined) {
|
||||||
|
return (prefix ?? "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.replace(/^\/+|\/+$/g, "")
|
||||||
|
.split("/")
|
||||||
|
.filter((segment) => segment && segment !== "." && segment !== "..")
|
||||||
|
.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildS3ObjectKey(
|
||||||
|
destination: Pick<S3StorageClientDestination, "objectPrefix">,
|
||||||
|
filename: string,
|
||||||
|
) {
|
||||||
|
const prefix = normalizeObjectPrefix(destination.objectPrefix);
|
||||||
|
return prefix ? `${prefix}/${filename}` : filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listS3Objects(
|
||||||
|
destination: S3StorageClientDestination,
|
||||||
|
) {
|
||||||
|
const client = createS3Client(destination);
|
||||||
|
const prefix = normalizeObjectPrefix(destination.objectPrefix);
|
||||||
|
const objects: Array<{
|
||||||
|
key: string;
|
||||||
|
lastModified: Date | null;
|
||||||
|
size: number;
|
||||||
|
}> = [];
|
||||||
|
let continuationToken: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const response = await client.send(
|
||||||
|
new ListObjectsV2Command({
|
||||||
|
Bucket: destination.bucket,
|
||||||
|
Prefix: prefix ? `${prefix}/` : undefined,
|
||||||
|
ContinuationToken: continuationToken,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const object of response.Contents ?? []) {
|
||||||
|
if (!object.Key) continue;
|
||||||
|
objects.push({
|
||||||
|
key: object.Key,
|
||||||
|
lastModified: object.LastModified ?? null,
|
||||||
|
size: object.Size ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
continuationToken = response.IsTruncated
|
||||||
|
? response.NextContinuationToken
|
||||||
|
: undefined;
|
||||||
|
} while (continuationToken);
|
||||||
|
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteS3Objects(
|
||||||
|
destination: S3StorageClientDestination,
|
||||||
|
keys: string[],
|
||||||
|
) {
|
||||||
|
if (keys.length === 0) return;
|
||||||
|
|
||||||
|
const client = createS3Client(destination);
|
||||||
|
for (let offset = 0; offset < keys.length; offset += 1000) {
|
||||||
|
const identifiers: ObjectIdentifier[] = keys
|
||||||
|
.slice(offset, offset + 1000)
|
||||||
|
.map((key) => ({ Key: key }));
|
||||||
|
|
||||||
|
const response = await client.send(
|
||||||
|
new DeleteObjectsCommand({
|
||||||
|
Bucket: destination.bucket,
|
||||||
|
Delete: { Objects: identifiers, Quiet: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.Errors && response.Errors.length > 0) {
|
||||||
|
const failedKeys = response.Errors.map((error) => error.Key).filter(
|
||||||
|
(key): key is string => Boolean(key),
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to delete ${failedKeys.length || response.Errors.length} S3 object(s): ${failedKeys.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { getExpiredObjectKeys } from "../../src/server/services/s3-storage-retention.service";
|
||||||
|
|
||||||
|
const now = Date.parse("2026-07-15T00:00:00.000Z");
|
||||||
|
|
||||||
|
function destination(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "destination-1",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
name: "Backups",
|
||||||
|
bucket: "bucket",
|
||||||
|
endpoint: null,
|
||||||
|
region: "ap-southeast-1",
|
||||||
|
accessKeyId: "access-key",
|
||||||
|
secretAccessKeyEnc: "encrypted",
|
||||||
|
additionalFlags: [],
|
||||||
|
retentionDays: null,
|
||||||
|
maxBackupCount: null,
|
||||||
|
objectPrefix: "doktainer/backups",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("retention removes objects older than the configured number of days", () => {
|
||||||
|
const keys = getExpiredObjectKeys(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: "old.tar.gz",
|
||||||
|
lastModified: new Date("2026-07-01T00:00:00.000Z"),
|
||||||
|
size: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recent.tar.gz",
|
||||||
|
lastModified: new Date("2026-07-14T00:00:00.000Z"),
|
||||||
|
size: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
destination({ retentionDays: 7 }),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(keys, ["old.tar.gz"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maximum backup count keeps the newest objects", () => {
|
||||||
|
const keys = getExpiredObjectKeys(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: "oldest.tar.gz",
|
||||||
|
lastModified: new Date("2026-07-10T00:00:00.000Z"),
|
||||||
|
size: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "newest.tar.gz",
|
||||||
|
lastModified: new Date("2026-07-14T00:00:00.000Z"),
|
||||||
|
size: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "middle.tar.gz",
|
||||||
|
lastModified: new Date("2026-07-12T00:00:00.000Z"),
|
||||||
|
size: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
destination({ maxBackupCount: 2 }),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(keys, ["oldest.tar.gz"]);
|
||||||
|
});
|
||||||
|
|
||||||
Reference in new issue
Block a user