diff --git a/.dockerignore b/.dockerignore index 56634a26..d68ee769 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,8 @@ node_modules # Next.js build output .next out +custom-server.js +*.tsbuildinfo # Local test infrastructure (pnpm test:ui), including its own .next cache .test @@ -12,12 +14,32 @@ out # Production folders (should be volume mounted, not baked into image) backups storage +data db/*.db db/*.db-journal +prisma/*.db +prisma/*.db-* + +# Local test and canary data +.canary +.ssh-test +**/.ssh + +# Certificates and private keys +**/*.key +**/*.pem +**/*.crt +**/*.cer +**/*.p12 +**/*.pfx +**/*.ppk +**/id_rsa +**/id_dsa +**/id_ecdsa +**/id_ed25519 # Environment variables -.env*.local -.env +.env* # Version control .git @@ -29,6 +51,9 @@ db/*.db-journal Dockerfile docker-compose*.yml .dockerignore +.codex-docker-context +.docker-build-context* +.docker-context* # IDEs .vscode diff --git a/Dockerfile b/Dockerfile index 85f14b67..e82aebc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -132,10 +132,9 @@ RUN printf '#!/bin/sh\nexec /usr/share/dotnet/dotnet /opt/sqlpackage/sqlpackage. chmod +x /usr/local/bin/sqlpackage && \ sqlpackage /version -# Enable corepack for pnpm support and symlink PostgreSQL 18 binaries +# Symlink PostgreSQL 18 binaries into PATH. # On Debian with PGDG, pg binaries live under /usr/lib/postgresql/18/bin/ -RUN corepack enable && corepack prepare pnpm@10.29.3 --activate && \ - ln -sf /usr/lib/postgresql/18/bin/pg_dump /usr/local/bin/pg_dump && \ +RUN ln -sf /usr/lib/postgresql/18/bin/pg_dump /usr/local/bin/pg_dump && \ ln -sf /usr/lib/postgresql/18/bin/pg_restore /usr/local/bin/pg_restore && \ ln -sf /usr/lib/postgresql/18/bin/psql /usr/local/bin/psql @@ -143,39 +142,148 @@ RUN corepack enable && corepack prepare pnpm@10.29.3 --activate && \ RUN pg_dump --version | grep -q 'PostgreSQL) 18\.' || \ (echo "ERROR: pg_dump version validation failed! Check PostgreSQL 18 client package." && exit 1) -# 1. Install Dependencies -FROM base AS deps +# Keep pnpm and its cross-platform Corepack cache out of the runtime image. +FROM base AS target-build-base +RUN corepack enable && corepack prepare pnpm@10.29.3 --activate + +# Install target-platform dependencies only for native runtime payloads. +FROM target-build-base AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm-${TARGETARCH},target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile + +# Install only the lock-resolved Prisma CLI and its runtime dependencies. +FROM deps AS prisma-cli +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN --mount=type=cache,id=pnpm-${TARGETARCH},target=/root/.local/share/pnpm/store \ + PRISMA_VERSION="$(node -p 'require("/app/node_modules/prisma/package.json").version')" && \ + pnpm add --global "prisma@${PRISMA_VERSION}" && \ + prisma --version + +# Generate the target-platform Prisma client and Sharp native packages. The full +# dependency tree remains in this disposable stage and never reaches the image. +FROM deps AS target-native +COPY prisma/schema.prisma ./prisma/schema.prisma +RUN pnpm prisma generate && \ + PRISMA_CLIENT="$(node -e 'const path = require("node:path"); process.stdout.write(path.resolve(path.dirname(require.resolve("@prisma/client/package.json")), "../../.prisma/client"))')" && \ + test -d "$PRISMA_CLIENT" && \ + mkdir -p /target-native && \ + cp -aL "$PRISMA_CLIENT" /target-native/prisma-client && \ + SHARP_ARCH="$(node -p 'process.arch')" && \ + copy_target_sharp_package() { \ + PACKAGE="$1"; \ + PACKAGE_METADATA="$2"; \ + SOURCE_PACKAGE="$(node -e 'const { createRequire } = require("node:module"); const sharpRequire = createRequire(require.resolve("sharp")); process.stdout.write(sharpRequire.resolve(process.argv[1]))' "$PACKAGE_METADATA")" || return 1; \ + test -s "$SOURCE_PACKAGE" || return 1; \ + DESTINATION="/target-native/node_modules/$PACKAGE"; \ + mkdir -p "$(dirname "$DESTINATION")" && \ + cp -aL "$(dirname "$SOURCE_PACKAGE")" "$DESTINATION"; \ + }; \ + copy_target_sharp_package "@img/sharp-linux-$SHARP_ARCH" "@img/sharp-linux-$SHARP_ARCH/package" && \ + copy_target_sharp_package "@img/sharp-libvips-linux-$SHARP_ARCH" "@img/sharp-libvips-linux-$SHARP_ARCH/package" + +# Build the CPU-neutral application once on the native Linux build platform. +FROM --platform=$BUILDPLATFORM node:24-slim AS app-build-base +ARG BUILDARCH +RUN apt-get update && apt-get install -y --no-install-recommends openssl util-linux && \ + rm -rf /var/lib/apt/lists/* && \ + corepack enable && \ + corepack prepare pnpm@10.29.3 --activate + +FROM app-build-base AS app-deps +ARG BUILDARCH WORKDIR /app COPY package.json pnpm-lock.yaml ./ -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \ +RUN --mount=type=cache,id=pnpm-${BUILDARCH},target=/root/.local/share/pnpm/store \ pnpm install --frozen-lockfile -# 2. Builder Phase -FROM base AS builder +FROM app-build-base AS builder +ARG BUILDARCH WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules +COPY --from=app-deps /app/node_modules ./node_modules COPY . . # Environment variables for build ENV NEXT_TELEMETRY_DISABLED=1 -ENV NODE_OPTIONS="--max-old-space-size=4096" +ENV DBACKUP_DOCKER_BUILD=1 +ENV RAYON_NUM_THREADS=1 +ENV TOKIO_WORKER_THREADS=1 +ENV NEXT_WEBPACK_PARALLELISM=1 +ENV NODE_OPTIONS="--max-old-space-size=1792" + +# Generate Prisma Client and build the Next.js app. +# The Docker-only Next.js config externalizes large Node-only dependency graphs +# and delegates type-checking to fresh TypeScript processes below. +RUN --mount=type=cache,id=next-build-${BUILDARCH},target=/app/.next/cache \ + pnpm prisma generate && \ + BUILD_CPU="$(awk '$1 == "Cpus_allowed_list:" { split($2, groups, ","); split(groups[1], range, "-"); print range[1] }' /proc/self/status)" && \ + test -n "$BUILD_CPU" && \ + taskset --cpu-list "$BUILD_CPU" pnpm exec next build --webpack + +# Sharp is a direct runtime dependency, but Next's explicit Sharp trace can omit +# pnpm-hoisted transitive packages. Copy only Sharp's target-native runtime closure. +RUN SHARP_ARCH="$(node -p 'process.arch')" && \ + copy_sharp_package() { \ + PACKAGE="$1"; \ + PACKAGE_METADATA="$2"; \ + SOURCE_PACKAGE="$(node -e 'const { createRequire } = require("node:module"); const sharpRequire = createRequire(require.resolve("sharp")); process.stdout.write(sharpRequire.resolve(process.argv[1]))' "$PACKAGE_METADATA")" || return 1; \ + test -s "$SOURCE_PACKAGE" || return 1; \ + DESTINATION=".next/standalone/node_modules/$PACKAGE"; \ + mkdir -p "$(dirname "$DESTINATION")" && \ + rm -rf "$DESTINATION" && \ + cp -aL "$(dirname "$SOURCE_PACKAGE")" "$DESTINATION"; \ + }; \ + copy_sharp_package detect-libc detect-libc/package.json && \ + copy_sharp_package semver semver/package.json && \ + copy_sharp_package @img/colour @img/colour/package.json && \ + copy_sharp_package "@img/sharp-linux-$SHARP_ARCH" "@img/sharp-linux-$SHARP_ARCH/package" && \ + copy_sharp_package "@img/sharp-libvips-linux-$SHARP_ARCH" "@img/sharp-libvips-linux-$SHARP_ARCH/package" + +# Compile-check the application and custom server in separate cacheable processes. +RUN \ + NODE_OPTIONS="--max-old-space-size=2560" pnpm exec tsc --noEmit --incremental false && \ + NODE_OPTIONS="--max-old-space-size=2560" pnpm exec tsc -p tsconfig.server.json --incremental false -# Generate Prisma Client, build Next.js app, and compile custom server -# --mount=type=cache persists the Next.js incremental build cache (.next/cache) -# across Docker builds via GitHub Actions cache (type=gha,mode=max in release.yml). -# Next.js reuses webpack/SWC artefacts for unchanged modules, cutting rebuild time significantly. -RUN --mount=type=cache,id=next-cache,target=/app/.next/cache \ - pnpm prisma generate && pnpm run build && npx tsc -p tsconfig.server.json +# Verify that standalone output is complete and contains only Linux native addons. +RUN test -s .next/standalone/server.js && \ + test -s .next/standalone/.next/required-server-files.json && \ + test -s .next/standalone/.next/BUILD_ID && \ + test -d .next/standalone/node_modules/next && \ + test -s custom-server.js && \ + node --check custom-server.js && \ + test -n "$(find .next/standalone -type f -name 'libquery_engine-*.so.node' -print -quit)" && \ + test -n "$(find .next/standalone -type f -path '*sharp-linux-*' -name '*.node' -print -quit)" && \ + ! grep -R -E --include='*.nft.json' 'query_engine-windows|sharp-win32' .next && \ + node -e 'const { createRequire } = require("node:module"); const runtimeRequire = createRequire("/app/.next/standalone/server.js"); for (const dependency of ["next", "@prisma/client", "sharp"]) { const resolved = runtimeRequire.resolve(dependency); if (!resolved.startsWith("/app/.next/standalone/")) throw new Error(`${dependency} resolved outside standalone output: ${resolved}`); runtimeRequire(dependency) }' && \ + find .next/standalone -type f -name '*.node' -exec sh -ec 'for file do signature=$(od -An -tx1 -N4 "$file" | tr -d " \n"); test "$signature" = 7f454c46 || { echo "Non-ELF native addon: $file ($signature)" >&2; exit 1; }; done' sh {} + && \ + BAD_NATIVE="$(find .next/standalone -type f \( -name '*windows*.node' -o -name '*.dll.node' -o -path '*/sharp-win32-*/*' \) -print -quit)" && \ + test -z "$BAD_NATIVE" + +# Remove build-platform native payloads before the runtime stage copies the +# standalone tree. Target-native Prisma and Sharp files are grafted below. +FROM builder AS portable-builder +RUN PRISMA_CLIENT="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const runtimeRequire = createRequire("/app/.next/standalone/server.js"); process.stdout.write(path.resolve(path.dirname(runtimeRequire.resolve("@prisma/client/package.json")), "../../.prisma/client"))')" && \ + test -d "$PRISMA_CLIENT" && \ + rm -rf "$PRISMA_CLIENT" && \ + rm -rf \ + .next/standalone/node_modules/@img/sharp-linux-* \ + .next/standalone/node_modules/@img/sharp-libvips-linux-* \ + .next/standalone/node_modules/.pnpm/@img+sharp-linux-* \ + .next/standalone/node_modules/.pnpm/@img+sharp-libvips-linux-* && \ + test -z "$(find .next/standalone -type f -name '*.node' -print -quit)" # 3. Runner Phase (The actual image) FROM base AS runner +ARG TARGETARCH WORKDIR /app # The Recovery Kit reads this off disk when a user downloads one, so it has to be in the # image. A missing file is not a build error - the kit is generated with a placeholder # apologising for its absence, which nobody discovers until they need it. Guarded by # tests/unit/lint-guards/recovery-kit-shipped.test.ts. -COPY --from=builder --link --chown=1001:1001 /app/scripts/dbackup-recover.js ./scripts/dbackup-recover.js +COPY --from=portable-builder --link --chown=1001:1001 /app/scripts/dbackup-recover.js ./scripts/dbackup-recover.js ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 @@ -193,29 +301,43 @@ RUN groupadd --system --gid 1001 nodejs && \ useradd --system --uid 1001 --gid nodejs --no-create-home nextjs # Copy built files (--link for better layer caching) -COPY --from=builder --link --chown=1001:1001 /app/public ./public -COPY --from=builder --link --chown=1001:1001 /app/.next/standalone ./ -COPY --from=builder --link --chown=1001:1001 /app/.next/static ./.next/static -COPY --from=builder --link --chown=1001:1001 /app/prisma ./prisma - -# Create runtime data directory + install Prisma CLI for migrations -# Note: pnpm add -g runs as root, so we must chown /pnpm to the runtime user -# to avoid "Can't write to @prisma/engines" errors at container startup -# Prisma version is read from package.json to stay in sync automatically -COPY --from=builder --link /app/package.json /tmp/package.json +COPY --from=portable-builder --link --chown=1001:1001 /app/public ./public +COPY --from=portable-builder --link --chown=1001:1001 /app/.next/standalone ./ +COPY --from=portable-builder --link --chown=1001:1001 /app/.next/static ./.next/static +COPY --from=portable-builder --link --chown=1001:1001 /app/prisma ./prisma + +# Graft only the target-platform native runtime payload into the portable tree. +RUN --mount=type=bind,from=target-native,source=/target-native,target=/target-native,ro \ + RUNTIME_CLIENT="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const runtimeRequire = createRequire("/app/server.js"); process.stdout.write(path.resolve(path.dirname(runtimeRequire.resolve("@prisma/client/package.json")), "../../.prisma/client"))')" && \ + mkdir -p "$RUNTIME_CLIENT" /app/node_modules/@img && \ + cp -a /target-native/prisma-client/. "$RUNTIME_CLIENT"/ && \ + cp -a /target-native/node_modules/@img/. /app/node_modules/@img/ && \ + chown -R 1001:1001 "$RUNTIME_CLIENT" /app/node_modules/@img + +# Create runtime data directories and copy the minimal Prisma CLI tree. RUN mkdir -p /data/storage/avatars /data/db /data/certs && \ - chown -R 1001:1001 /data && \ - PRISMA_VERSION=$(node -e "console.log(require('/tmp/package.json').devDependencies.prisma.replace(/[\^~>=<]/g,''))") && \ - pnpm add -g prisma@${PRISMA_VERSION} && \ - rm /tmp/package.json && \ - chown -R 1001:1001 /pnpm + chown -R 1001:1001 /data +COPY --from=prisma-cli --link --chown=1001:1001 /pnpm /pnpm # Copy compiled custom HTTPS server (replaces default Next.js server entry point) -COPY --from=builder --link --chown=1001:1001 /app/custom-server.js ./custom-server.js +COPY --from=portable-builder --link --chown=1001:1001 /app/custom-server.js ./custom-server.js + +# Fail the build if standalone tracing missed required native runtime packages. +RUN node --check custom-server.js && \ + prisma --version && \ + node -e 'Promise.all(["@aws-sdk/lib-storage", "@microsoft/microsoft-graph-client", "dockerode", "dropbox", "googleapis", "mssql", "ssh2", "ssh2-sftp-client"].map(async (dependency) => { try { await import(dependency); console.log(`${dependency}: available`) } catch (error) { console.error(`${dependency}: ${error.code ?? error.message}`); process.exitCode = 1 } }))' && \ + node -e 'require("@prisma/client"); require("sharp")' && \ + test -n "$(find /app/node_modules -type f -name 'libquery_engine-*.so.node' -print -quit)" && \ + test -n "$(find /app/node_modules -type f -path '*sharp-linux-*' -name '*.node' -print -quit)" && \ + EXPECTED_MACHINE="$(case "$TARGETARCH" in amd64) echo 3e00 ;; arm64) echo b700 ;; *) exit 1 ;; esac)" && \ + find / -xdev -type f -name '*.node' -exec sh -ec 'expected="$1"; shift; for file do signature=$(od -An -tx1 -N4 "$file" | tr -d " \n"); machine=$(od -An -tx1 -j18 -N2 "$file" | tr -d " \n"); test "$signature" = 7f454c46 && test "$machine" = "$expected" || { echo "Wrong native addon: $file (signature=$signature machine=$machine expected=$expected)" >&2; exit 1; }; done' sh "$EXPECTED_MACHINE" {} + && \ + BAD_NATIVE="$(find / -xdev -type f \( -name '*windows*.node' -o -name '*.dll.node' -o -path '*/sharp-win32-*/*' \) -print -quit)" && \ + test -z "$BAD_NATIVE" # Copy entrypoint script COPY docker-entrypoint.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/docker-entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \ + chmod +x /usr/local/bin/docker-entrypoint.sh # Health check: verify app + database are reachable # Uses --insecure for self-signed certs; falls back to http if DISABLE_HTTPS=true diff --git a/docs/changelog.md b/docs/changelog.md index 336e6557..4543a5d0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,22 @@ All notable changes to DBackup are documented here. +## vNEXT +*Release: In Progress* + +### ✨ Features + +- **MongoDB**: Backup jobs can now create and restore one native full instance archive containing all databases, users and custom roles. ([#152](https://github.com/Skyfay/DBackup/pull/152)) + +### 🐛 Bug Fixes + +- **restore**: Metadata marked with no compression is now restored without entering decompression. ([#152](https://github.com/Skyfay/DBackup/pull/152)) +- **restore**: Concurrent restores now use isolated temporary files so metadata and archives cannot overwrite each other. ([#152](https://github.com/Skyfay/DBackup/pull/152)) + +### 🔧 CI/CD + +- **docker**: Docker images now use native Linux standalone output without copying the complete production dependency tree. ([#152](https://github.com/Skyfay/DBackup/pull/152)) + ## v3.3.0 - Azure SQL Database Support, S3 Upload Rework and General Improvements *Released: Aug 15, 2026* diff --git a/docs/user-guide/sources/mongodb.md b/docs/user-guide/sources/mongodb.md index c8e7cf0a..141b124d 100644 --- a/docs/user-guide/sources/mongodb.md +++ b/docs/user-guide/sources/mongodb.md @@ -66,6 +66,10 @@ mongorestore mongosh ``` +::: info Full Instance tool version +Full Instance backup and restore require MongoDB Database Tools 100.3 or newer on the host that runs the tools. The DBackup Docker image already meets this requirement. In SSH mode, install a compatible version on the remote host. +::: + **Install on the remote host:**
@@ -168,6 +172,31 @@ For Atlas clusters, create a user with "Backup Admin" role in the Atlas UI. ## Backup Process +### Backup Scope + +MongoDB jobs provide two backup scopes: + +- **Selected Databases** is the default and preserves the existing behavior. Choose one or more databases in the job. Restore can select individual databases and map them to different target names. +- **Full Instance** runs one native `mongodump` archive for the server. It includes every database available to the backup user, plus MongoDB users and custom roles. The database selector is hidden because this scope cannot be narrowed to individual databases. + +Existing jobs and backup files without scope metadata are always treated as **Selected Databases**. + +Full Instance executes the equivalent of: + +```bash +mongodump --archive= --gzip +``` + +MongoDB's normal `mongodump` rules still apply. For example, server-local replication data in the `local` database is not a portable part of an instance backup. + +::: warning Permissions +Use a credential with the `backup` role on `admin`. Without sufficient access to the `admin` database, the archive may not contain the users and custom roles needed for a complete instance restore. +::: + +::: warning Replica Set Consistency +Full Instance is a logical dump and currently does not add `--oplog`. On an active replica set, writes can continue while collections are being dumped, so the archive is not a point-in-time-consistent snapshot. Use a maintenance window or MongoDB's coordinated snapshot tooling when point-in-time consistency is required. +::: + ### Direct Mode DBackup uses `mongodump` which creates a binary BSON dump: @@ -358,9 +387,26 @@ To restore a MongoDB backup: 2. Find your backup file 3. Click **Restore** 4. Select target database configuration -5. Optionally map database names +5. For a **Selected Databases** backup, optionally select and map database names 6. Confirm and monitor progress +For a **Full Instance** backup, DBackup hides database mapping and restores the complete native archive. + +### Full Instance Restore Safety + +A Full Instance restore is destructive. It runs `mongorestore` with `--drop` and replaces the target's MongoDB users and custom roles with the definitions from the backup. Authentication credentials on the target can therefore change during the restore. + +The credential DBackup uses must continue to work after those users are replaced. The safest approach is to use a restore administrator that exists both on the target and in the backup with the same username, password, authentication database, and required restore permissions. Otherwise MongoDB can reject new connections part-way through the restore, leaving the target only partially restored. + +Before starting: + +1. Stop applications that write to the target. +2. Back up the target instance or use a disposable restore target. +3. Confirm that the DBackup target credential will remain valid after the source users and roles are restored. +4. Keep the target MongoDB version compatible with the backup version. + +After the restore succeeds, verify application data, users, and custom roles before directing production traffic to the target. + ### Restore Options - **Drop existing data**: Clean restore diff --git a/next.config.ts b/next.config.ts index ed235781..231d35b6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,50 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from "next" + +const isDockerBuild = process.env.DBACKUP_DOCKER_BUILD === "1" + +const dockerBuildConfig = { + serverExternalPackages: [ + "@aws-sdk/lib-storage", + "@microsoft/microsoft-graph-client", + "dockerode", + "dropbox", + "googleapis", + "mssql", + "ssh2", + "ssh2-sftp-client", + ], + experimental: { + cpus: 1, + webpackBuildWorker: true, + webpackMemoryOptimizations: true, + }, + outputFileTracingIncludes: { + "/*": ["node_modules/sharp/**/*"], + }, + typescript: { + ignoreBuildErrors: true, + }, + webpack(config, { dev, isServer, webpack }) { + if (!dev && config.cache && typeof config.cache === "object") { + config.cache = { + ...config.cache, + maxMemoryGenerations: 0, + } + } + + if (!isServer) { + config.plugins.push( + new webpack.NormalModuleReplacementPlugin(/^node:crypto$/, "crypto"), + ) + } + + return config + }, +} satisfies NextConfig const nextConfig: NextConfig = { - /* config options here */ output: "standalone", -}; + ...(isDockerBuild ? dockerBuildConfig : {}), +} -export default nextConfig; +export default nextConfig diff --git a/package.json b/package.json index 96047383..dd4d7bd4 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "recharts": "3.10.1", "rsync": "^0.6.1", "samba-client": "^7.2.0", + "sharp": "^0.35.3", "sonner": "^2.0.7", "ssh2": "^1.17.0", "ssh2-sftp-client": "^12.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc04b94..14f31e8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: samba-client: specifier: ^7.2.0 version: 7.2.0 + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@24.12.2) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) diff --git a/prisma/migrations/20260819090000_add_mongodb_backup_scope/migration.sql b/prisma/migrations/20260819090000_add_mongodb_backup_scope/migration.sql new file mode 100644 index 00000000..278d2424 --- /dev/null +++ b/prisma/migrations/20260819090000_add_mongodb_backup_scope/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Job" ADD COLUMN "backupScope" TEXT NOT NULL DEFAULT 'SELECTED_DATABASES'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1da6c60b..893c1441 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -129,6 +129,7 @@ model Job { enabled Boolean @default(true) sourceId String? databases String @default("[]") // JSON array of database names to back up (e.g. ["db1","db2"]) + backupScope String @default("SELECTED_DATABASES") // MongoDB: SELECTED_DATABASES | FULL_INSTANCE encryptionProfileId String? encryptionProfile EncryptionProfile? @relation(fields: [encryptionProfileId], references: [id]) compression String @default("NONE") diff --git a/src/app/api/jobs/[id]/route.ts b/src/app/api/jobs/[id]/route.ts index f72510b9..de7eb37a 100644 --- a/src/app/api/jobs/[id]/route.ts +++ b/src/app/api/jobs/[id]/route.ts @@ -3,6 +3,8 @@ import { headers } from "next/headers"; import { jobService } from "@/services/jobs/job-service"; import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control"; import { PERMISSIONS } from "@/lib/auth/permissions"; +import { MongoDBBackupScopeSchema } from "@/lib/core/mongodb-backup-scope"; +import { ValidationError } from "@/lib/logging/errors"; export async function DELETE( req: NextRequest, @@ -38,7 +40,13 @@ export async function PUT( const params = await props.params; try { const body = await req.json(); - const { name, schedule, sourceId, databases, destinations, sources, notificationIds, notificationTemplateIds, enabled, encryptionProfileId, compression, pgCompression, notificationEvents, namingTemplateId, schedulePresetId, skipVerification, backupMode, fullEveryDays, verifyByHash } = body; + const { name, schedule, sourceId, databases, backupScope, destinations, sources, notificationIds, notificationTemplateIds, enabled, encryptionProfileId, compression, pgCompression, notificationEvents, namingTemplateId, schedulePresetId, skipVerification, backupMode, fullEveryDays, verifyByHash } = body; + const parsedBackupScope = backupScope === undefined + ? undefined + : MongoDBBackupScopeSchema.safeParse(backupScope); + if (parsedBackupScope && !parsedBackupScope.success) { + return NextResponse.json({ error: "Invalid MongoDB backup scope" }, { status: 400 }); + } const updatedJob = await jobService.updateJob(params.id, { name, @@ -46,6 +54,7 @@ export async function PUT( enabled, sourceId, databases: Array.isArray(databases) ? databases : undefined, + backupScope: parsedBackupScope?.data, destinations: destinations ? destinations.map((d: { configId: string; priority?: number; retention?: any; retentionPolicyId?: string | null }, i: number) => ({ configId: d.configId, priority: d.priority ?? i, @@ -78,6 +87,12 @@ export async function PUT( return NextResponse.json(updatedJob); } catch (error: unknown) { + if (error instanceof ValidationError) { + return NextResponse.json( + { error: error.message, details: error.details }, + { status: 400 } + ); + } const message = error instanceof Error ? error.message : "Failed to update job"; const status = message.includes("already exists") ? 409 : 500; return NextResponse.json({ error: message }, { status }); diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts index e47a1d92..ea92277f 100644 --- a/src/app/api/jobs/route.ts +++ b/src/app/api/jobs/route.ts @@ -4,9 +4,10 @@ import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-co import { PERMISSIONS } from "@/lib/auth/permissions"; import { jobService } from "@/services/jobs/job-service"; import { logger } from "@/lib/logging/logger"; -import { wrapError } from "@/lib/logging/errors"; +import { ValidationError, wrapError } from "@/lib/logging/errors"; import { Cron } from "croner"; import prisma from "@/lib/prisma"; +import { MongoDBBackupScopeSchema } from "@/lib/core/mongodb-backup-scope"; const log = logger.child({ route: "jobs" }); @@ -59,7 +60,11 @@ export async function POST(req: NextRequest) { checkPermissionWithContext(ctx, PERMISSIONS.JOBS.WRITE); const body = await req.json(); - const { name, schedule, sourceId, databases, destinations, sources, notificationIds, notificationTemplateIds, enabled, encryptionProfileId, compression, pgCompression, notificationEvents, namingTemplateId, schedulePresetId, skipVerification, backupMode, fullEveryDays, verifyByHash } = body; + const { name, schedule, sourceId, databases, backupScope, destinations, sources, notificationIds, notificationTemplateIds, enabled, encryptionProfileId, compression, pgCompression, notificationEvents, namingTemplateId, schedulePresetId, skipVerification, backupMode, fullEveryDays, verifyByHash } = body; + const parsedBackupScope = MongoDBBackupScopeSchema.safeParse(backupScope ?? "SELECTED_DATABASES"); + if (!parsedBackupScope.success) { + return NextResponse.json({ error: "Invalid MongoDB backup scope" }, { status: 400 }); + } if (!name || !schedule || !destinations || !Array.isArray(destinations) || destinations.length === 0) { return NextResponse.json({ error: "Missing required fields (name, schedule, destinations)" }, { status: 400 }); @@ -70,6 +75,7 @@ export async function POST(req: NextRequest) { schedule, sourceId: sourceId || undefined, databases: Array.isArray(databases) ? databases : [], + backupScope: parsedBackupScope.data, destinations: destinations.map((d: { configId: string; priority?: number; retention?: any; retentionPolicyId?: string | null }, i: number) => ({ configId: d.configId, priority: d.priority ?? i, @@ -104,6 +110,12 @@ export async function POST(req: NextRequest) { return NextResponse.json(newJob, { status: 201 }); } catch (error: unknown) { + if (error instanceof ValidationError) { + return NextResponse.json( + { error: error.message, details: error.details }, + { status: 400 } + ); + } log.error("Create job error", {}, wrapError(error)); const message = error instanceof Error ? error.message : "Failed to create job"; const status = message.includes("already exists") ? 409 : 500; diff --git a/src/app/api/storage/[id]/analyze/route.ts b/src/app/api/storage/[id]/analyze/route.ts index 9c639e1a..f6305ca6 100644 --- a/src/app/api/storage/[id]/analyze/route.ts +++ b/src/app/api/storage/[id]/analyze/route.ts @@ -83,12 +83,17 @@ export async function POST(req: NextRequest, props: { params: Promise<{ id: stri const metaPath = file + ".meta.json"; const metaContent = await storageAdapter.read(sConf, metaPath); if (metaContent) { - const meta = JSON.parse(metaContent); - - if ((meta as BackupMetadata).archive?.formatVersion === 2) { - seekableArchiveMeta = meta as BackupMetadata; + const meta = JSON.parse(metaContent) as BackupMetadata; + // Older and selected-database backups intentionally omit this from the + // response. Only the destructive full-instance mode changes the restore UI. + const backupScope = meta.backupScope === "FULL_INSTANCE" + ? { backupScope: "FULL_INSTANCE" as const, sourceType: "mongodb" } + : {}; + + if (meta.archive?.formatVersion === 2) { + seekableArchiveMeta = meta; const summary = await archiveIndexService.summarize(params.id, file, seekableArchiveMeta, keyOverride); - if (summary) return NextResponse.json(summary); + if (summary) return NextResponse.json({ ...summary, ...backupScope }); // Sidecar missing or unreadable. Deliberately NOT falling through to // the legacy shortcuts below - they only understand databases and // would silently drop this archive's directory sources. The embedded @@ -97,22 +102,23 @@ export async function POST(req: NextRequest, props: { params: Promise<{ id: stri if (!seekableArchiveMeta && !(meta.combined && meta.combined.directorySources > 0)) { if (meta.databases) { - if (Array.isArray(meta.databases.names) && meta.databases.names.length > 0) { - return NextResponse.json({ databases: meta.databases.names }); + if (!Array.isArray(meta.databases) && Array.isArray(meta.databases.names) && meta.databases.names.length > 0) { + return NextResponse.json({ databases: meta.databases.names, ...backupScope }); } if (Array.isArray(meta.databases) && meta.databases.length > 0) { - return NextResponse.json({ databases: meta.databases }); + return NextResponse.json({ databases: meta.databases, ...backupScope }); } } // For multi-DB TAR archives, return the embedded database list - if (meta.multiDb?.databases?.length > 0) { - return NextResponse.json({ databases: meta.multiDb.databases }); + const multiDbDatabases = meta.multiDb?.databases; + if (multiDbDatabases && multiDbDatabases.length > 0) { + return NextResponse.json({ databases: multiDbDatabases, ...backupScope }); } // For server-based adapters (not sqlite) with empty names, // use the source type to signal the frontend that this is a DB restore const serverAdapters = ['mysql', 'mariadb', 'postgres', 'mongodb', 'mssql', 'azure-sql', 'redis', 'valkey', 'firebird']; if (meta.sourceType && serverAdapters.includes(meta.sourceType.toLowerCase())) { - return NextResponse.json({ databases: [], sourceType: meta.sourceType }); + return NextResponse.json({ databases: [], sourceType: meta.sourceType, ...backupScope }); } } } diff --git a/src/app/api/storage/[id]/restore/route.ts b/src/app/api/storage/[id]/restore/route.ts index 04135832..89514a0f 100644 --- a/src/app/api/storage/[id]/restore/route.ts +++ b/src/app/api/storage/[id]/restore/route.ts @@ -7,6 +7,7 @@ import { PERMISSIONS } from "@/lib/auth/permissions"; import { logger } from "@/lib/logging/logger"; import { wrapError, getErrorMessage } from "@/lib/logging/errors"; import prisma from "@/lib/prisma"; +import { MongoDBBackupScopeSchema } from "@/lib/core/mongodb-backup-scope"; const log = logger.child({ route: "storage/restore" }); @@ -23,17 +24,25 @@ export async function POST(req: NextRequest, props: { params: Promise<{ id: stri checkPermissionWithContext(ctx, PERMISSIONS.STORAGE.RESTORE); const body = await req.json(); - const { file, scope, targetSourceId, targetDatabaseName, databaseMapping, directoryMapping, excludePatterns, privilegedAuth, profileIdOverride } = body; + const { file, scope, backupScope, targetSourceId, targetDatabaseName, databaseMapping, directoryMapping, excludePatterns, privilegedAuth, profileIdOverride } = body; if (!file || typeof file !== 'string' || file.includes('..') || file.startsWith('/')) { return NextResponse.json({ error: "Invalid file path" }, { status: 400 }); } + const parsedBackupScope = backupScope === undefined + ? undefined + : MongoDBBackupScopeSchema.safeParse(backupScope); + if (parsedBackupScope && !parsedBackupScope.success) { + return NextResponse.json({ error: "Invalid MongoDB backup scope" }, { status: 400 }); + } + const user = await prisma.user.findUnique({ where: { id: ctx.userId }, select: { name: true } }); const result = await restoreService.restore({ storageConfigId: params.id, file, + backupScope: parsedBackupScope?.data, // Anything unrecognised restores everything, same as omitting it. scope: scope === 'databases' || scope === 'files' ? scope : undefined, targetSourceId: targetSourceId || undefined, diff --git a/src/app/dashboard/storage/restore/restore-client.tsx b/src/app/dashboard/storage/restore/restore-client.tsx index e3b5a8be..16cf1f6a 100644 --- a/src/app/dashboard/storage/restore/restore-client.tsx +++ b/src/app/dashboard/storage/restore/restore-client.tsx @@ -37,6 +37,7 @@ import { computeRestoreValidity } from "./restore-validation"; import { parseRestoreScope, normalizeRestoreScope } from "@/components/dashboard/storage/restore-scope"; import { EncryptionKeyResolutionDialog, type KeyResolutionResult } from "@/components/common/encryption-key-resolution-dialog"; import { keyOverrideBody, useEncryptionKeyRecovery, type KeyOverrideBody } from "@/hooks/use-encryption-key-recovery"; +import type { MongoDBBackupScope } from "@/lib/core/mongodb-backup-scope"; interface DatabaseInfo { name: string; @@ -77,6 +78,14 @@ interface ChainInfo { deps: string[]; } +interface StorageAnalyzeResponse { + databases?: string[]; + directories?: DirectoryAnalysis[]; + sourceType?: string; + backupScope?: MongoDBBackupScope; + chain?: ChainInfo; +} + interface DirConfig { entryId: string; label: string; @@ -133,6 +142,7 @@ export function RestoreClient({ canManageVault = false }: RestoreClientProps) { const [analyzedDbs, setAnalyzedDbs] = useState([]); const [dbConfig, setDbConfig] = useState([]); const [backupSourceType, setBackupSourceType] = useState(""); + const [backupScope, setBackupScope] = useState(); /** Why the backup could not be read, so the page explains itself instead of staying blank. */ const [analyzeError, setAnalyzeError] = useState(null); @@ -203,6 +213,7 @@ export function RestoreClient({ canManageVault = false }: RestoreClientProps) { const SERVER_ADAPTERS = ['mysql', 'mariadb', 'postgres', 'mongodb', 'mssql', 'azure-sql', 'redis', 'valkey', 'firebird']; const resolvedSourceType = backupSourceType || file?.sourceType || ''; const isServerAdapter = SERVER_ADAPTERS.includes(resolvedSourceType.toLowerCase()); + const isMongoFullInstance = resolvedSourceType.toLowerCase() === 'mongodb' && backupScope === 'FULL_INSTANCE'; // Firebird's target field holds a filesystem path, not a database name - and since // Firebird has no way to list existing databases, the Overwrite/New badge is replaced // with a neutral "Unverified" indicator for this adapter. @@ -373,6 +384,8 @@ export function RestoreClient({ canManageVault = false }: RestoreClientProps) { const analyzeBackup = useCallback(async (file: FileInfo, resolvedKey?: KeyResolutionResult) => { setIsAnalyzing(true); + // Do not carry a destructive scope marker across a file change or failed re-analysis. + setBackupScope(undefined); try { const res = await fetch(`/api/storage/${destinationId}/analyze`, { method: 'POST', @@ -394,7 +407,12 @@ export function RestoreClient({ canManageVault = false }: RestoreClientProps) { setAnalyzeError(null); { - const data = await res.json(); + const data = await res.json() as StorageAnalyzeResponse; + setBackupScope( + data.backupScope === 'FULL_INSTANCE' || data.backupScope === 'SELECTED_DATABASES' + ? data.backupScope + : undefined + ); if (data.sourceType) { setBackupSourceType(data.sourceType); } @@ -633,7 +651,7 @@ export function RestoreClient({ canManageVault = false }: RestoreClientProps) { try { let mapping = undefined; - if (analyzedDbs.length > 0) { + if (!isMongoFullInstance && analyzedDbs.length > 0) { // The full list including deselected entries: an entry with selected:false // is how the backend knows a database is NOT wanted. Sending only the // selected ones would collapse "none selected" into an empty mapping, @@ -665,10 +683,13 @@ export function RestoreClient({ canManageVault = false }: RestoreClientProps) { // reports the untouched half as skipped. scope: restoreScope, targetSourceId: targetSource || undefined, + // The backend verifies this hint against the backup sidecar before it + // permits a destructive full-instance restore. + ...(backupScope ? { backupScope } : {}), // Note: restoreMode only gates the non-server-adapter RadioGroup UI (which // clears targetDbName on "overwrite"); the server-adapter Input paths set // targetDbName directly, so its truthiness alone is the correct signal here. - targetDatabaseName: targetDbName || undefined, + targetDatabaseName: isMongoFullInstance ? undefined : targetDbName || undefined, databaseMapping: mapping, directoryMapping, ...(excludePatterns.length > 0 ? { excludePatterns } : {}), @@ -862,6 +883,9 @@ export function RestoreClient({ canManageVault = false }: RestoreClientProps) { {file.isEncrypted && ( Encrypted )} + {isMongoFullInstance && ( + Full Instance + )} {/* Only shown for a narrowed scope, so it is clear why one half of a combined backup is missing from the page. */} {(!wantsDatabases || !wantsFiles) && ( @@ -992,8 +1016,26 @@ export function RestoreClient({ canManageVault = false }: RestoreClientProps) { + {isMongoFullInstance && ( + + + MongoDB Full Instance Restore + +

+ This restores every database in the archive, including MongoDB users and custom roles. + Database selection and renaming are unavailable for a Full Instance restore. +

+

+ The restore uses --drop. Users and custom roles on the target are replaced by + the definitions in the archive, so authentication credentials may change. Make sure the credential DBackup uses will still be valid after + replacement, or the restore can stop part-way through. +

+
+
+ )} + {/* Database Mapping Card */} - {targetSource && ( + {targetSource && !isMongoFullInstance && (
@@ -1601,10 +1643,14 @@ export function RestoreClient({ canManageVault = false }: RestoreClientProps) { - - Warning + {isMongoFullInstance ? : } + + {isMongoFullInstance ? 'Full Instance Restore Is Destructive' : 'Warning'} + - This action is irreversible. Ensure you have a backup of the target if needed. + {isMongoFullInstance + ? 'All databases in the archive are restored with --drop. Target MongoDB users and custom roles are replaced, so the credential DBackup is currently using may stop working.' + : 'This action is irreversible. Ensure you have a backup of the target if needed.'} diff --git a/src/components/dashboard/jobs/job-form.tsx b/src/components/dashboard/jobs/job-form.tsx index 1e88d152..2a53fbe5 100644 --- a/src/components/dashboard/jobs/job-form.tsx +++ b/src/components/dashboard/jobs/job-form.tsx @@ -30,6 +30,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; +import { MONGODB_BACKUP_SCOPE_VALUES } from "@/lib/core/mongodb-backup-scope"; import { Command, CommandEmpty, @@ -172,6 +173,7 @@ const jobSchema = z.object({ schedule: z.string().min(1, "Cron schedule is required"), sourceId: z.string().optional().default(""), databases: z.array(z.string()).default([]), + backupScope: z.enum(MONGODB_BACKUP_SCOPE_VALUES).default("SELECTED_DATABASES"), directorySources: z.array(directorySourceSchema).default([]), destinations: z.array(destinationSchema).min(1, "At least one destination is required"), encryptionProfileId: z.string().optional(), @@ -195,6 +197,13 @@ const jobSchema = z.object({ message: "Select a database source or add at least one directory source", }); } + if (data.backupScope === "FULL_INSTANCE" && data.directorySources.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["backupScope"], + message: "Full Instance cannot be combined with directory sources", + }); + } // Same adapter + same path twice is a foot-gun (not a hard conflict like destinations' // unique-configId constraint, since one adapter legitimately backing up two different // paths is fine) - block only the exact duplicate. @@ -228,6 +237,7 @@ export interface JobFormProps { enabled: boolean; sourceId: string | null; databases?: string; + backupScope?: string; encryptionProfileId?: string; compression: string; pgCompression?: string; @@ -382,6 +392,7 @@ export function JobForm({ sources, destinations, directorySourceOptions, notific schedule: initialData?.schedule || "0 0 * * *", sourceId: initialData?.sourceId || "", databases: parseInitialDatabases(), + backupScope: initialData?.backupScope === "FULL_INSTANCE" ? "FULL_INSTANCE" : "SELECTED_DATABASES", directorySources: defaultDirectorySources, destinations: defaultDestinations, encryptionProfileId: initialData?.encryptionProfileId || "no-encryption", @@ -450,6 +461,7 @@ export function JobForm({ sources, destinations, directorySourceOptions, notific } form.setValue("sourceId", "", { shouldDirty: true, shouldValidate: true }); form.setValue("databases", [], { shouldDirty: true }); + form.setValue("backupScope", "SELECTED_DATABASES", { shouldDirty: true }); setAvailableDatabases([]); setIsDbListOpen(false); } else if (dirsEnabled) { @@ -540,7 +552,11 @@ export function JobForm({ sources, destinations, directorySourceOptions, notific // Determine whether to show database picker based on selected source adapter const selectedSourceId = form.watch("sourceId"); const selectedSource = sources.find(s => s.id === selectedSourceId); - const showDatabasePicker = selectedSource && !["sqlite", "redis", "valkey"].includes(selectedSource.adapterId); + const backupScope = form.watch("backupScope"); + const isMongoSource = selectedSource?.adapterId === "mongodb"; + const showDatabasePicker = selectedSource + && !["sqlite", "redis", "valkey"].includes(selectedSource.adapterId) + && (!isMongoSource || backupScope === "SELECTED_DATABASES"); // In "both" mode only combinable DB adapters (dumpOne()/restoreOne() support) are offered, so the // combination is valid by construction - JobService.validateJobSources remains the server-side backstop. const sourcePickerOptions = sourceMode === "both" @@ -921,6 +937,7 @@ export function JobForm({ sources, destinations, directorySourceOptions, notific // Reset databases when source changes if (prevSourceId !== s.id) { form.setValue("databases", []); + form.setValue("backupScope", "SELECTED_DATABASES"); setAvailableDatabases([]); } }} @@ -936,6 +953,33 @@ export function JobForm({ sources, destinations, directorySourceOptions, notific + {isMongoSource && ( + ( + + Backup Scope + + + {scopeField.value === "FULL_INSTANCE" + ? "Backs up all databases, MongoDB users and custom roles in one native archive." + : dirsEnabled + ? "Selected Databases is required when directory sources are included." + : "Backs up only the databases selected below using the existing per-database format."} + + + + )} /> + )} + {/* Database Picker (hidden for SQLite/Redis/Valkey) - same left-adapter/right-load-and-select layout as a Directory Source row */} {showDatabasePicker ? ( ( diff --git a/src/lib/adapters/database/mongodb/args.ts b/src/lib/adapters/database/mongodb/args.ts index 70ac8f3c..9369af3d 100644 --- a/src/lib/adapters/database/mongodb/args.ts +++ b/src/lib/adapters/database/mongodb/args.ts @@ -1,4 +1,5 @@ import type { MongoDBConfig } from "@/lib/adapters/definitions"; +import type { ExecutionHost } from "@/lib/transport" /** * Connection arguments and URIs for the MongoDB tools. @@ -132,6 +133,90 @@ export function buildConnectionArgs(config: AnyMongoConfig): string[] { return args; } +/** + * Connection flags for an operation that must cover the whole MongoDB instance. + * + * A database in a legacy inline URI acts like a database selection for + * the MongoDB database tools. Remove that path while keeping its authentication + * meaning and every existing URI option intact. + */ +export function buildFullInstanceConnectionArgs(config: AnyMongoConfig): string[] { + if (!config.uri) return buildConnectionArgs(config); + + const match = /^(mongodb(?:\+srv)?:\/\/)([^/?#]+)(?:\/([^?#]*))?(\?[^#]*)?(#.*)?$/i.exec(config.uri); + if (!match) return buildConnectionArgs(config); + + const [, scheme, authority, database = "", query = "", fragment = ""] = match; + const queryBody = query.startsWith("?") ? query.slice(1) : query; + const normalizedQuery = queryBody ? `?${queryBody}` : ""; + const hasAuthSource = /(?:^|[&;])authSource=/i.test(queryBody); + const authSource = database && !hasAuthSource + ? `${queryBody ? "&" : "?"}authSource=${database}` + : ""; + + return [`--uri=${scheme}${authority}/${normalizedQuery}${authSource}${fragment}`]; +} + +/** + * Run a MongoDB database tool without exposing URI credentials or passwords in argv. + * + * MongoDB Database Tools 100.3 and newer support a 0600 YAML config file for + * sensitive connection values. The execution host creates that file beside the + * tool, including over SSH, and removes it after the process exits. + */ +export async function withMongoToolConnectionArgs( + host: ExecutionHost, + connectionArgs: string[], + fn: (args: string[]) => Promise, +): Promise { + const safeArgs: string[] = [] + let uri: string | undefined + let password: string | undefined + + let index = 0 + while (index < connectionArgs.length) { + const arg = connectionArgs[index] + + if (arg.startsWith("--uri=")) { + uri = arg.slice("--uri=".length) + index++ + continue + } + if (arg === "--uri" && connectionArgs[index + 1] !== undefined) { + uri = connectionArgs[index + 1] + index += 2 + continue + } + if (arg.startsWith("--password=")) { + password = arg.slice("--password=".length) + index++ + continue + } + if (arg === "--password" && connectionArgs[index + 1] !== undefined) { + password = connectionArgs[index + 1] + index += 2 + continue + } + + safeArgs.push(arg) + index++ + } + + if (uri === undefined && password === undefined) { + return fn(safeArgs) + } + + const configContent = [ + uri === undefined ? undefined : `uri: ${JSON.stringify(uri)}`, + password === undefined ? undefined : `password: ${JSON.stringify(password)}`, + ].filter((line): line is string => line !== undefined).join("\n") + "\n" + + return host.withTempFile( + { content: configContent, mode: 0o600, suffix: ".yaml" }, + (configPath) => fn([`--config=${configPath}`, ...safeArgs]), + ) +} + /** * Connection arguments for mongosh. * diff --git a/src/lib/adapters/database/mongodb/dump.ts b/src/lib/adapters/database/mongodb/dump.ts index 4f9b3287..6104a913 100644 --- a/src/lib/adapters/database/mongodb/dump.ts +++ b/src/lib/adapters/database/mongodb/dump.ts @@ -1,5 +1,11 @@ import type { ExecutionHost } from "@/lib/transport"; -import { MONGODUMP, buildConnectionArgs, maskSecrets } from "./args"; +import { + MONGODUMP, + buildConnectionArgs, + buildFullInstanceConnectionArgs, + maskSecrets, + withMongoToolConnectionArgs, +} from "./args" import { BackupResult } from "@/lib/core/interfaces"; import { LogLevel, LogType } from "@/lib/core/logs"; import fs from "fs/promises"; @@ -12,14 +18,56 @@ import { import { TarFileEntry, TarManifest } from "../common/types"; import { MongoDBConfig } from "@/lib/adapters/definitions"; import { getDatabases } from "./connection"; +import type { MongoDBBackupScope } from "@/lib/core/mongodb-backup-scope"; +import { AdapterError } from "@/lib/logging/errors"; /** * Extended MongoDB config for dump operations with runtime fields */ type MongoDBDumpConfig = MongoDBConfig & { detectedVersion?: string; + backupScope?: MongoDBBackupScope; }; +async function dumpFullInstance( + outputPath: string, + config: MongoDBDumpConfig, + host: ExecutionHost, + log: (msg: string, level?: LogLevel, type?: LogType, details?: string) => void +): Promise { + const mongodump = await host.which(...MONGODUMP); + + await host.captureOutput(outputPath, {}, async (hostPath) => { + const connectionArgs = buildFullInstanceConnectionArgs(config) + + await withMongoToolConnectionArgs(host, connectionArgs, async (secureConnectionArgs) => { + const args = [ + ...secureConnectionArgs, + `--archive=${hostPath}`, + "--gzip", + ] + + log("Dumping full MongoDB instance", "info", "command", `${mongodump} ${maskSecrets(args, config.password)}`) + + const proc = await host.spawn([mongodump, ...args]) + proc.stdout.on("data", () => { /* mongodump writes progress to stderr */ }) + proc.stderr.on("data", (data: Buffer) => { + const msg = data.toString().trim() + if (msg) log(`[mongodump] ${msg}`, "info") + }) + + const { code, signal } = await proc.exit() + if (code !== 0) { + throw new AdapterError( + "mongodb", + "full instance dump", + `mongodump exited with code ${code ?? "null"}${signal ? ` (signal: ${signal})` : ""}`, + ) + } + }) + }); +} + /** * Dump a single MongoDB database with mongodump --archive --gzip. * @@ -112,6 +160,24 @@ export async function dump( let tempDir: string | null = null; try { + if (config.backupScope === "FULL_INSTANCE") { + await dumpFullInstance(destinationPath, config, _host, log); + + const stats = await fs.stat(destinationPath); + if (stats.size === 0) { + throw new AdapterError("mongodb", "full instance dump", "Dump file is empty. Check logs/permissions."); + } + + return { + success: true, + path: destinationPath, + size: stats.size, + logs, + startedAt, + completedAt: new Date(), + }; + } + // Prepare DB list let dbs: string[] = []; if (Array.isArray(config.database)) { diff --git a/src/lib/adapters/database/mongodb/restore.ts b/src/lib/adapters/database/mongodb/restore.ts index 0e665f4b..c4e78762 100644 --- a/src/lib/adapters/database/mongodb/restore.ts +++ b/src/lib/adapters/database/mongodb/restore.ts @@ -1,10 +1,18 @@ import type { ExecutionHost } from "@/lib/transport"; -import { MONGORESTORE, buildConnectionArgs, maskSecrets } from "./args"; +import { + MONGORESTORE, + buildConnectionArgs, + buildFullInstanceConnectionArgs, + maskSecrets, + withMongoToolConnectionArgs, +} from "./args" import { withMongoMeta } from "./meta"; import { BackupResult } from "@/lib/core/interfaces"; import { LogLevel, LogType } from "@/lib/core/logs"; import { MongoDBConfig } from "@/lib/adapters/definitions"; import path from "path"; +import type { MongoDBBackupScope } from "@/lib/core/mongodb-backup-scope"; +import { AdapterError } from "@/lib/logging/errors"; import { isMultiDbTar, extractSelectedDatabases, @@ -16,6 +24,7 @@ import { /** Extended config with optional privileged auth for restore operations */ type MongoDBRestoreConfig = MongoDBConfig & { + backupScope?: MongoDBBackupScope; privilegedAuth?: { user: string; password: string }; detectedVersion?: string; databaseMapping?: Array<{ @@ -29,6 +38,27 @@ type MongoDBRestoreConfig = MongoDBConfig & { targetDatabaseName?: string; }; +function buildRestoreUsageConfig(config: MongoDBRestoreConfig): MongoDBConfig { + const usageConfig: MongoDBConfig = { ...config } + if (!config.privilegedAuth) return usageConfig + + usageConfig.user = config.privilegedAuth.user + usageConfig.password = config.privilegedAuth.password + + // A legacy URI takes precedence over the separate credential fields. Full + // Instance restores must use the explicitly confirmed privileged credential. + if (config.backupScope === "FULL_INSTANCE" && usageConfig.uri) { + const encodedUser = encodeURIComponent(config.privilegedAuth.user) + const encodedPassword = encodeURIComponent(config.privilegedAuth.password) + usageConfig.uri = usageConfig.uri.replace( + /^(mongodb(?:\+srv)?:\/\/)(?:[^/?#@]*@)?/i, + `$1${encodedUser}:${encodedPassword}@`, + ) + } + + return usageConfig +} + /** * Build MongoDB connection URI from config */ @@ -42,11 +72,7 @@ export async function prepareRestore( // Probe with the privileged credentials when they are configured, since // those are the ones the restore itself will use. - const usageConfig: MongoDBConfig = { ...config }; - if (config.privilegedAuth) { - usageConfig.user = config.privilegedAuth.user; - usageConfig.password = config.privilegedAuth.password; - } + const usageConfig = buildRestoreUsageConfig(config) await withMongoMeta(usageConfig, host, async (meta) => { for (const dbName of databases) { @@ -70,39 +96,59 @@ async function restoreSingleDatabase( log: (msg: string, level?: LogLevel, type?: LogType, details?: string) => void, ): Promise { const mongorestore = await host.which(...MONGORESTORE); + const usageConfig = buildRestoreUsageConfig(config) // mongorestore reads the archive from a path, so it is staged onto the // execution host. On a direct host that is the original file with no copy. await host.stageInput(sourcePath, {}, async (stagedPath) => { - const args = [ - ...buildConnectionArgs(config), - `--archive=${stagedPath}`, - "--gzip", - "--drop", // Drop collections before restoring, mirroring MySQL's --clean - ]; - - if (sourceDb && targetDb && sourceDb !== targetDb) { - args.push("--nsFrom", `${sourceDb}.*`); - args.push("--nsTo", `${targetDb}.*`); - log(`Remapping database: ${sourceDb} -> ${targetDb}`, "info"); - } else if (targetDb) { - args.push("--nsInclude", `${targetDb}.*`); - } - - log("Restoring database", "info", "command", `${mongorestore} ${maskSecrets(args, config.password)}`); + const connectionArgs = config.backupScope === "FULL_INSTANCE" + ? buildFullInstanceConnectionArgs(usageConfig) + : buildConnectionArgs(usageConfig) + + const runRestore = async (effectiveConnectionArgs: string[]) => { + const args = [ + ...effectiveConnectionArgs, + `--archive=${stagedPath}`, + "--gzip", + "--drop", // Drop collections before restoring, mirroring MySQL's --clean + ] + + if (config.backupScope === "FULL_INSTANCE") { + log( + "Restoring the full MongoDB instance replaces all databases, users and custom roles. The restore credential must also exist in the backup with matching credentials.", + "warning", + ) + } else if (sourceDb && targetDb && sourceDb !== targetDb) { + args.push("--nsFrom", `${sourceDb}.*`) + args.push("--nsTo", `${targetDb}.*`) + log(`Remapping database: ${sourceDb} -> ${targetDb}`, "info") + } else if (targetDb) { + args.push("--nsInclude", `${targetDb}.*`) + } - const proc = await host.spawn([mongorestore, ...args]); - proc.stdout.on("data", () => { /* mongorestore writes progress to stderr */ }); - proc.stderr.on("data", (data: Buffer) => { - const msg = data.toString().trim(); - if (msg) log(`[mongorestore] ${msg}`, "info"); - }); + log("Restoring database", "info", "command", `${mongorestore} ${maskSecrets(args, usageConfig.password)}`) + + const proc = await host.spawn([mongorestore, ...args]) + proc.stdout.on("data", () => { /* mongorestore writes progress to stderr */ }) + proc.stderr.on("data", (data: Buffer) => { + const msg = data.toString().trim() + if (msg) log(`[mongorestore] ${msg}`, "info") + }) + + const { code, signal } = await proc.exit() + if (code !== 0) { + throw new AdapterError( + "mongodb", + "restore", + `mongorestore exited with code ${code ?? "null"}${signal ? ` (signal: ${signal})` : ""}`, + ) + } + } - const { code, signal } = await proc.exit(); - if (code !== 0) { - throw new Error( - `mongorestore exited with code ${code ?? "null"}${signal ? ` (signal: ${signal})` : ""}`, - ); + if (config.backupScope === "FULL_INSTANCE") { + await withMongoToolConnectionArgs(host, connectionArgs, runRestore) + } else { + await runRestore(connectionArgs) } }); } diff --git a/src/lib/core/interfaces.ts b/src/lib/core/interfaces.ts index 8d7f8f0d..265802f2 100644 --- a/src/lib/core/interfaces.ts +++ b/src/lib/core/interfaces.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { LogLevel, LogType } from "./logs"; import type { AdapterCredentialRequirements } from "./credentials"; import type { ExecutionHost, TransportResolver } from "@/lib/transport/types"; +import type { MongoDBBackupScope } from "./mongodb-backup-scope"; /** * Base configuration type for adapters. @@ -27,11 +28,13 @@ export interface BackupMetadata { engineVersion?: string; engineEdition?: string; // e.g., "Express", "Standard", "Enterprise", "Azure SQL Edge" databases: string[] | { count: number; names?: string[] }; + /** MongoDB dump scope. Absent on older backups and means SELECTED_DATABASES. */ + backupScope?: MongoDBBackupScope; timestamp: string; originalFileName: string; sourceId: string; locked?: boolean; - compression?: 'GZIP' | 'BROTLI'; + compression?: 'NONE' | 'GZIP' | 'BROTLI'; encryption?: { enabled: boolean; profileId: string; diff --git a/src/lib/core/mongodb-backup-scope.ts b/src/lib/core/mongodb-backup-scope.ts new file mode 100644 index 00000000..5f004ee7 --- /dev/null +++ b/src/lib/core/mongodb-backup-scope.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const MONGODB_BACKUP_SCOPE_VALUES = ["SELECTED_DATABASES", "FULL_INSTANCE"] as const; + +export const MongoDBBackupScopeSchema = z.enum(MONGODB_BACKUP_SCOPE_VALUES); + +export type MongoDBBackupScope = z.infer; + +export function normalizeMongoDBBackupScope(value: unknown): MongoDBBackupScope { + const parsed = MongoDBBackupScopeSchema.safeParse(value); + return parsed.success ? parsed.data : "SELECTED_DATABASES"; +} diff --git a/src/lib/runner/steps/02-dump.ts b/src/lib/runner/steps/02-dump.ts index 9ebee598..bd35c5ff 100644 --- a/src/lib/runner/steps/02-dump.ts +++ b/src/lib/runner/steps/02-dump.ts @@ -21,6 +21,9 @@ export async function stepExecuteDump(ctx: RunnerContext) { if (!ctx.sourceAdapter && (!ctx.sources || ctx.sources.length === 0)) { throw new Error("Job has no source configured"); } + if (ctx.job.backupScope === "FULL_INSTANCE" && ctx.sources && ctx.sources.length > 0) { + throw new Error("MongoDB Full Instance backup cannot be combined with directory sources."); + } // Combined path (DB + directory sources, or directory-only) - only ever taken when a job // actually has JobSource rows. Every DB-only job (ctx.sources.length === 0, the 99% case) @@ -54,6 +57,9 @@ export async function stepExecuteDump(ctx: RunnerContext) { const sourceConfig = await resolveAdapterConfig(job.source!) as any; // Inject adapterId as type for Dialect selection (e.g. 'mariadb') sourceConfig.type = job.source!.adapterId; + if (job.source!.adapterId === "mongodb") { + sourceConfig.backupScope = job.backupScope ?? "SELECTED_DATABASES"; + } // One transport for the whole dump step: listing databases, probing the // server version and dumping used to open a separate SSH connection each. @@ -65,15 +71,20 @@ export async function stepExecuteDump(ctx: RunnerContext) { // Inject databases from Job (always takes precedence over source config). // An empty job selection means "backup all" - clear the source's default database // so each adapter's auto-discovery logic triggers instead of using the source default. - const jobDatabases: string[] = (() => { + const selectedJobDatabases: string[] = (() => { try { const parsed = JSON.parse(job.databases || "[]"); return Array.isArray(parsed) ? parsed : []; } catch { return []; } })(); + // A Full Instance dump ignores database selection. Clear legacy values here too so + // filenames and sidecar metadata describe what the archive actually contains. + const jobDatabases = sourceConfig.backupScope === "FULL_INSTANCE" + ? [] + : selectedJobDatabases; // 3. Generate filename with timezone and custom pattern - const dbNameRaw = jobDatabases.length === 0 + const dbNameRaw = sourceConfig.backupScope === "FULL_INSTANCE" || jobDatabases.length === 0 ? 'all' : jobDatabases.map(db => db.replace(/[^a-z0-9]/gi, '_')).join('_'); diff --git a/src/lib/runner/steps/03-upload.ts b/src/lib/runner/steps/03-upload.ts index 063f55b5..feafeec5 100644 --- a/src/lib/runner/steps/03-upload.ts +++ b/src/lib/runner/steps/03-upload.ts @@ -3,6 +3,7 @@ import path from "path"; import { describeBackupFromMetadata } from "@/services/storage/backup-file-fields"; import fs from "fs/promises"; import prisma from "@/lib/prisma"; +import { normalizeMongoDBBackupScope } from "@/lib/core/mongodb-backup-scope"; import { createReadStream, createWriteStream } from "fs"; import { pipeline } from "stream/promises"; import { BackupMetadata } from "@/lib/core/interfaces"; @@ -162,6 +163,9 @@ export async function stepUpload(ctx: RunnerContext) { count: typeof ctx.metadata?.count === 'number' ? ctx.metadata.count : 0, names: Array.isArray(ctx.metadata?.names) ? ctx.metadata.names : undefined }, + ...(job.source?.adapterId === "mongodb" + ? { backupScope: normalizeMongoDBBackupScope(job.backupScope) } + : {}), engineVersion: ctx.metadata?.engineVersion, engineEdition: ctx.metadata?.engineEdition, timestamp: new Date().toISOString(), diff --git a/src/services/jobs/job-service.ts b/src/services/jobs/job-service.ts index abe7ae93..976459f2 100644 --- a/src/services/jobs/job-service.ts +++ b/src/services/jobs/job-service.ts @@ -2,11 +2,12 @@ import prisma from "@/lib/prisma"; import { STORAGE_ROLES } from "@/lib/core/storage-roles"; import { scheduler } from "@/lib/server/scheduler"; import { logger } from "@/lib/logging/logger"; -import { wrapError } from "@/lib/logging/errors"; +import { NotFoundError, ValidationError, wrapError } from "@/lib/logging/errors"; import { registry } from "@/lib/core/registry"; import { registerAdapters } from "@/lib/adapters"; import { runBulk, type BulkResult } from "@/lib/core/bulk"; import type { DatabaseAdapter } from "@/lib/core/interfaces"; +import type { MongoDBBackupScope } from "@/lib/core/mongodb-backup-scope"; registerAdapters(); @@ -39,6 +40,7 @@ export interface CreateJobInput { schedule: string; sourceId?: string; databases?: string[]; + backupScope?: MongoDBBackupScope; destinations: DestinationInput[]; sources?: SourceInput[]; notificationIds?: string[]; @@ -61,6 +63,7 @@ export interface UpdateJobInput { schedule?: string; sourceId?: string; databases?: string[]; + backupScope?: MongoDBBackupScope; destinations?: DestinationInput[]; sources?: SourceInput[]; notificationIds?: string[]; @@ -233,8 +236,48 @@ export class JobService { } } + private async validateBackupScope( + jobId: string | null, + sourceId: string | undefined, + sources: SourceInput[] | undefined, + backupScope: CreateJobInput["backupScope"], + ) { + let effectiveSourceId = sourceId; + let effectiveSourceCount = sources?.length; + let effectiveBackupScope = backupScope; + + if (jobId && (effectiveSourceId === undefined || effectiveSourceCount === undefined || effectiveBackupScope === undefined)) { + const current = await prisma.job.findUnique({ + where: { id: jobId }, + select: { sourceId: true, backupScope: true, sources: { select: { id: true } } }, + }); + if (!current) throw new NotFoundError("Job", jobId); + if (effectiveSourceId === undefined) effectiveSourceId = current.sourceId ?? undefined; + if (effectiveSourceCount === undefined) effectiveSourceCount = current.sources.length; + if (effectiveBackupScope === undefined) { + effectiveBackupScope = current.backupScope === "FULL_INSTANCE" ? "FULL_INSTANCE" : "SELECTED_DATABASES"; + } + } + + if (effectiveBackupScope !== "FULL_INSTANCE") return; + if ((effectiveSourceCount ?? 0) > 0) { + throw new ValidationError("MongoDB Full Instance backup cannot be combined with directory sources.", { + field: "backupScope", + }); + } + + const source = effectiveSourceId + ? await prisma.adapterConfig.findUnique({ where: { id: effectiveSourceId }, select: { adapterId: true } }) + : null; + if (source?.adapterId !== "mongodb") { + throw new ValidationError("Full Instance backup scope is only available for MongoDB sources.", { + field: "backupScope", + }); + } + } + async createJob(input: CreateJobInput) { - const { name, schedule, sourceId, databases, destinations, sources, notificationIds, notificationTemplateIds, enabled, encryptionProfileId, compression, pgCompression, notificationEvents, skipVerification, backupMode, fullEveryDays, verifyByHash } = input; + const { name, schedule, sourceId, databases, backupScope, destinations, sources, notificationIds, notificationTemplateIds, enabled, encryptionProfileId, compression, pgCompression, notificationEvents, skipVerification, backupMode, fullEveryDays, verifyByHash } = input; // Check name uniqueness const existingByName = await prisma.job.findFirst({ where: { name } }); @@ -243,6 +286,7 @@ export class JobService { } await this.validateJobSources(null, sourceId || null, sources); + await this.validateBackupScope(null, sourceId, sources, backupScope); await this.validateJobDestinations(destinations); const newJob = await prisma.job.create({ @@ -251,6 +295,7 @@ export class JobService { schedule, sourceId: sourceId || null, databases: JSON.stringify(databases || []), + backupScope: backupScope ?? "SELECTED_DATABASES", enabled: enabled !== undefined ? enabled : true, encryptionProfileId: encryptionProfileId || null, namingTemplateId: input.namingTemplateId ?? null, @@ -305,7 +350,7 @@ export class JobService { } async updateJob(id: string, input: UpdateJobInput) { - const { name, schedule, sourceId, databases, destinations, sources, notificationIds, notificationTemplateIds, enabled, encryptionProfileId, compression, pgCompression, notificationEvents, namingTemplateId, skipVerification, backupMode, fullEveryDays, verifyByHash } = input; + const { name, schedule, sourceId, databases, backupScope, destinations, sources, notificationIds, notificationTemplateIds, enabled, encryptionProfileId, compression, pgCompression, notificationEvents, namingTemplateId, skipVerification, backupMode, fullEveryDays, verifyByHash } = input; // Check name uniqueness (excluding current job) if (name) { @@ -319,6 +364,9 @@ export class JobService { await this.validateJobSources(id, sourceId !== undefined ? (sourceId || null) : undefined, sources); await this.validateJobDestinations(destinations); } + if (sourceId !== undefined || sources !== undefined || backupScope !== undefined) { + await this.validateBackupScope(id, sourceId, sources, backupScope); + } const updatedJob = await prisma.$transaction(async (tx) => { // Update destinations if provided @@ -409,6 +457,7 @@ export class JobService { enabled, sourceId: sourceId !== undefined ? (sourceId || null) : undefined, databases: databases !== undefined ? JSON.stringify(databases) : undefined, + backupScope: backupScope !== undefined ? backupScope : undefined, compression, pgCompression, notificationEvents, @@ -532,6 +581,7 @@ export class JobService { schedule: original.schedule, sourceId: original.sourceId, databases: original.databases, + backupScope: original.backupScope, enabled: false, encryptionProfileId: original.encryptionProfileId ?? null, compression: original.compression, diff --git a/src/services/restore/pipeline.ts b/src/services/restore/pipeline.ts index c13d20e8..b50be909 100644 --- a/src/services/restore/pipeline.ts +++ b/src/services/restore/pipeline.ts @@ -16,7 +16,7 @@ import { getDecompressionStream, CompressionType } from "@/lib/crypto/compressio import { LogEntry, LogLevel, LogType, RESTORE_STAGES } from "@/lib/core/logs"; import { isMultiDbTar, readTarManifest } from "@/lib/adapters/database/common/tar-utils"; import { logger } from "@/lib/logging/logger"; -import { wrapError, getErrorMessage } from "@/lib/logging/errors"; +import { wrapError, getErrorMessage, RestoreError } from "@/lib/logging/errors"; import { verifyFileChecksum } from "@/lib/crypto/checksum"; import { notify } from "@/services/notifications/system-notification-service"; import { NOTIFICATION_EVENTS } from "@/lib/notifications"; @@ -26,6 +26,7 @@ import { processQueue } from "@/lib/execution/queue-manager"; import type { RestoreInput } from "./types"; import { resolveDecryptionKey } from "./smart-recovery"; import { restoreArchiveSnapshot } from "./archive-restore"; +import { MongoDBBackupScopeSchema } from "@/lib/core/mongodb-backup-scope"; const svcLog = logger.child({ service: "RestoreService" }); @@ -36,7 +37,16 @@ const svcLog = logger.child({ service: "RestoreService" }); * State (executionId, log buffer, stage, progress) is shared across all phases via closures. */ export async function runRestorePipeline(executionId: string, input: RestoreInput): Promise { - const { storageConfigId, file, targetSourceId, targetDatabaseName, databaseMapping, privilegedAuth } = input; + const { + storageConfigId, + file, + backupScope: requestedBackupScope, + targetSourceId, + targetDatabaseName, + databaseMapping, + privilegedAuth, + } = input; + const fullInstanceExpected = requestedBackupScope === "FULL_INSTANCE"; let tempFile: string | null = null; const restoreStartTime = Date.now(); const abortController = registerExecution(executionId); @@ -165,8 +175,9 @@ export async function runRestorePipeline(executionId: string, input: RestoreInpu // path this is, so it never claims a download that will not happen. setStage(RESTORE_STAGES.DOWNLOADING); log(`Reading backup metadata: ${file}...`, 'info'); - const tempDir = getTempDir(); - tempFile = path.join(tempDir, path.basename(file)); + const tempDir = getTempDir() + const tempPrefix = `restore-${executionId}` + tempFile = path.join(tempDir, `${tempPrefix}-${path.basename(file)}`) const sConf = await resolveAdapterConfig(storageConfig) as any; @@ -176,16 +187,44 @@ export async function runRestorePipeline(executionId: string, input: RestoreInpu let compressionMeta: CompressionType | undefined = undefined; let expectedChecksum: string | undefined = undefined; let seekableArchive = false; + let backupScope: BackupMetadata['backupScope'] = undefined; + const tempMetaPath = path.join(tempDir, `${tempPrefix}.meta.json`) try { const metaRemotePath = file + ".meta.json"; - const tempMetaPath = path.join(getTempDir(), "meta_" + Date.now() + ".json"); - const metaDownSuccess = await storageAdapter.download(sConf, metaRemotePath, tempMetaPath, () => {}).catch(() => false); + if (!metaDownSuccess && fullInstanceExpected) { + throw new RestoreError( + "Full Instance restore requires its metadata sidecar. The restore was stopped before changing the target.", + { executionId, sourcePath: file }, + ); + } + if (metaDownSuccess) { const metaContent = await fs.promises.readFile(tempMetaPath, 'utf-8'); - const metadata = JSON.parse(metaContent); + const metadata = JSON.parse(metaContent) as BackupMetadata; + const parsedMetadataScope = MongoDBBackupScopeSchema.safeParse(metadata.backupScope); + const metadataScope = parsedMetadataScope.success + ? parsedMetadataScope.data + : "SELECTED_DATABASES"; + + if (metadataScope === "FULL_INSTANCE" && !fullInstanceExpected) { + throw new RestoreError( + "This backup is a MongoDB Full Instance archive. Confirm the restore scope before continuing.", + { executionId, sourcePath: file }, + ); + } + + if (fullInstanceExpected && metadataScope !== "FULL_INSTANCE") { + throw new RestoreError( + "The selected file is not confirmed as a MongoDB Full Instance backup. The restore was stopped before changing the target.", + { executionId, sourcePath: file }, + ); + } + backupScope = fullInstanceExpected && metadataScope === "FULL_INSTANCE" + ? "FULL_INSTANCE" + : undefined; if (metadata.archive?.formatVersion === 2) { // Seekable archive - restored by byte range below, never by full @@ -229,10 +268,18 @@ export async function runRestorePipeline(executionId: string, input: RestoreInpu } } catch { /* ignore connection tests during restore init */ } } - - await fs.promises.unlink(tempMetaPath).catch(() => {}); } } catch (e: unknown) { + if (e instanceof RestoreError) throw e; + + if (fullInstanceExpected) { + const cause = e instanceof Error ? e : new Error(String(e)); + throw new RestoreError( + "Could not verify the MongoDB Full Instance backup metadata. The restore was stopped before changing the target.", + { executionId, sourcePath: file, cause }, + ); + } + const message = e instanceof Error ? e.message : String(e); log(`Warning: Failed to check sidecar metadata: ${message}`, 'warning'); @@ -243,9 +290,26 @@ export async function runRestorePipeline(executionId: string, input: RestoreInpu } if (file.endsWith('.gz')) compressionMeta = 'GZIP'; if (file.endsWith('.br')) compressionMeta = 'BROTLI'; + } finally { + await fs.promises.unlink(tempMetaPath).catch(() => {}); } // --- END METADATA CHECK --- + if (backupScope === "FULL_INSTANCE") { + if (!sourceConfig || sourceConfig.adapterId !== "mongodb") { + throw new RestoreError( + "MongoDB Full Instance backups can only be restored to a MongoDB target.", + { executionId, sourcePath: file }, + ); + } + if (seekableArchive) { + throw new RestoreError( + "MongoDB Full Instance backups must use the native archive format.", + { executionId, sourcePath: file }, + ); + } + } + // --- SEEKABLE (v2) ARCHIVE: restore by byte range, never by full download --- // The archive is opened remotely; on adapters with ranged reads only the selected // entries are transferred. Downloading it here - like the v1 path below does - @@ -406,7 +470,7 @@ export async function runRestorePipeline(executionId: string, input: RestoreInpu // --- END DECRYPTION EXECUTION --- // --- DECOMPRESSION EXECUTION --- - if (compressionMeta && compressionMeta !== 'NONE') { + if (compressionMeta === 'GZIP' || compressionMeta === 'BROTLI') { try { log(`Decompressing backup (${compressionMeta})...`, 'info'); setStage(RESTORE_STAGES.DECOMPRESSING); @@ -482,6 +546,10 @@ export async function runRestorePipeline(executionId: string, input: RestoreInpu const dbConf = await resolveAdapterConfig(sourceConfig) as any; // Inject adapterId as type for Dialect selection dbConf.type = sourceConfig.adapterId; + const isMongoFullInstance = sourceConfig.adapterId === "mongodb" && backupScope === "FULL_INSTANCE"; + if (isMongoFullInstance) { + dbConf.backupScope = "FULL_INSTANCE"; + } // CRITICAL: Detect target server version for version-matched binary selection if (sourceAdapter.test) { @@ -505,7 +573,7 @@ export async function runRestorePipeline(executionId: string, input: RestoreInpu } // Override database name if provided - if (targetDatabaseName) { + if (!isMongoFullInstance && targetDatabaseName) { if (sourceConfig.adapterId === 'sqlite' && dbConf.path) { const dir = path.dirname(dbConf.path); dbConf.path = path.join(dir, targetDatabaseName); @@ -516,7 +584,7 @@ export async function runRestorePipeline(executionId: string, input: RestoreInpu } } - if (databaseMapping) { + if (!isMongoFullInstance && databaseMapping) { dbConf.databaseMapping = databaseMapping; // For SQLite: getDatabases() returns the filename, so a mapping entry means diff --git a/src/services/restore/types.ts b/src/services/restore/types.ts index 5761b302..43da3883 100644 --- a/src/services/restore/types.ts +++ b/src/services/restore/types.ts @@ -1,4 +1,5 @@ import type { TriggerInfo } from "@/lib/runner"; +import type { MongoDBBackupScope } from "@/lib/core/mongodb-backup-scope"; /** Selects a directory entry (from a seekable v2 archive) to restore, and where to. */ export interface DirectoryRestoreMapping { @@ -29,6 +30,11 @@ export type RestoreScope = 'all' | 'databases' | 'files'; export interface RestoreInput { storageConfigId: string; file: string; + /** + * Scope reported by the backup analyzer. Full Instance restores require the + * downloaded sidecar to confirm this value before any restore command runs. + */ + backupScope?: MongoDBBackupScope; /** Defaults to 'all', which is what every request written before scopes existed means. */ scope?: RestoreScope; /** Optional - a directory-only archive has no database target. Required whenever the diff --git a/tests/unit/adapters/database/mongodb/args.test.ts b/tests/unit/adapters/database/mongodb/args.test.ts index 7f0af70f..5ee18912 100644 --- a/tests/unit/adapters/database/mongodb/args.test.ts +++ b/tests/unit/adapters/database/mongodb/args.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { buildConnectionArgs, + buildFullInstanceConnectionArgs, buildConnectionUri, buildShellConnectionArgs, maskSecrets, @@ -149,6 +150,24 @@ describe("buildConnectionArgs", () => { }); }); +describe("buildFullInstanceConnectionArgs", () => { + it("removes a database path while preserving credentials and URI options", () => { + const uri = "mongodb://legacy:pw@mongo.internal:27017/shop?authSource=admin&replicaSet=rs0"; + + expect(buildFullInstanceConnectionArgs({ ...withAuth, uri } as never)).toEqual([ + "--uri=mongodb://legacy:pw@mongo.internal:27017/?authSource=admin&replicaSet=rs0", + ]); + }); + + it("preserves the path database as authSource when the URI relied on that default", () => { + const uri = "mongodb+srv://legacy:pw@cluster.example.com/app?retryWrites=true"; + + expect(buildFullInstanceConnectionArgs({ ...withAuth, uri } as never)).toEqual([ + "--uri=mongodb+srv://legacy:pw@cluster.example.com/?retryWrites=true&authSource=app", + ]); + }); +}); + describe("buildShellConnectionArgs", () => { it("passes the connection string positionally, because mongosh has no --uri flag", () => { const args = buildShellConnectionArgs({ ...withAuth, host: "cluster0.ab12c.mongodb.net" } as never); diff --git a/tests/unit/adapters/database/mongodb/dump.test.ts b/tests/unit/adapters/database/mongodb/dump.test.ts index e6f585f1..cd2273ab 100644 --- a/tests/unit/adapters/database/mongodb/dump.test.ts +++ b/tests/unit/adapters/database/mongodb/dump.test.ts @@ -100,6 +100,71 @@ describe.each(["direct", "ssh"])("MongoDB dump over a %s host", (kind) expect(mockGetDatabases).toHaveBeenCalledWith(expect.anything(), host); }); + it("uses one native archive dump for a full instance", async () => { + const host = dumpHost(kind); + + const result = await dump({ + ...baseConfig, + database: ["shop", "analytics"], + backupScope: "FULL_INSTANCE", + options: "--db narrowed --collection users --query={}", + } as never, "/tmp/out.archive", host); + + expect(result.success).toBe(true); + expect(host.calls.spawn).toHaveLength(1); + expect(host.calls.spawn[0]).toEqual([ + "mongodump", + "--config=/tmp/fake_1.yaml", + "--host", + "mongo.internal", + "--port", + "27017", + "--username", + "root", + "--authenticationDatabase", + "admin", + "--archive=/tmp/out.archive", + "--gzip", + ]); + expect(host.calls.tempFiles).toEqual([{ + path: "/tmp/fake_1.yaml", + content: 'password: "secret"\n', + mode: 0o600, + }]) + expect(host.calls.removed).toContain("/tmp/fake_1.yaml") + expect(mockGetDatabases).not.toHaveBeenCalled(); + expect(mockCreateMultiDbTar).not.toHaveBeenCalled(); + }); + + it("removes the full instance credentials file when mongodump fails", async () => { + const host = dumpHost(kind, { code: 1 }) + + const result = await dump({ + ...baseConfig, + backupScope: "FULL_INSTANCE", + } as never, "/tmp/out.archive", host) + + expect(result.success).toBe(false) + expect(host.calls.removed).toContain(host.calls.tempFiles[0].path) + }) + + it("removes the database path from a legacy URI for a full instance", async () => { + const host = dumpHost(kind); + + await dump({ + ...baseConfig, + backupScope: "FULL_INSTANCE", + uri: "mongodb://legacy:pw@mongo.internal:27017/shop?authSource=admin&replicaSet=rs0", + } as never, "/tmp/out.archive", host); + + expect(host.calls.spawn[0]).not.toContainEqual(expect.stringContaining("legacy:pw")) + expect(host.calls.tempFiles[0]).toEqual({ + path: "/tmp/fake_1.yaml", + content: 'uri: "mongodb://legacy:pw@mongo.internal:27017/?authSource=admin&replicaSet=rs0"\n', + mode: 0o600, + }) + }); + it("packs several databases into a TAR", async () => { mockCreateMultiDbTar.mockResolvedValue({ databases: [{ name: "a" }, { name: "b" }] }); const host = dumpHost(kind); diff --git a/tests/unit/adapters/database/mongodb/restore.test.ts b/tests/unit/adapters/database/mongodb/restore.test.ts index 71b8a1f8..ec37a700 100644 --- a/tests/unit/adapters/database/mongodb/restore.test.ts +++ b/tests/unit/adapters/database/mongodb/restore.test.ts @@ -105,12 +105,114 @@ describe.each(["direct", "ssh"])("MongoDB restore over a %s host", (ki expect(host.calls.spawn[0]).not.toContain("--archive"); }); + it("restores a full instance without namespace filters", async () => { + const host = restoreHost(kind); + + const result = await restore({ + ...baseConfig, + backupScope: "FULL_INSTANCE", + } as never, "/tmp/full-instance.archive", host); + + expect(result.success).toBe(true); + const argv = host.calls.spawn[0]; + expect(argv).toContain("--drop"); + expect(argv).not.toContain("--nsInclude"); + expect(argv).not.toContain("--nsFrom"); + expect(argv).not.toContain("--nsTo"); + expect(argv).toContain(`--config=${host.calls.tempFiles[0].path}`) + expect(argv).not.toContain("--password") + expect(argv).not.toContain("secret") + expect(host.calls.tempFiles[0]).toMatchObject({ + content: 'password: "secret"\n', + mode: 0o600, + }) + expect(host.calls.removed).toContain(host.calls.tempFiles[0].path) + }) + + it("removes the database path from a legacy URI for a full instance restore", async () => { + const host = restoreHost(kind) + + const result = await restore({ + ...baseConfig, + uri: "mongodb://legacy:pw@mongo.internal:27017/shop?authSource=admin&replicaSet=rs0#client", + backupScope: "FULL_INSTANCE", + } as never, "/tmp/full-instance.archive", host) + + expect(result.success).toBe(true) + expect(host.calls.tempFiles).toHaveLength(1) + expect(host.calls.tempFiles[0]).toMatchObject({ mode: 0o600 }) + expect(host.calls.tempFiles[0].content).toContain( + 'uri: "mongodb://legacy:pw@mongo.internal:27017/?authSource=admin&replicaSet=rs0#client"', + ) + const argv = host.calls.spawn[0] + expect(argv).toContain(`--config=${host.calls.tempFiles[0].path}`) + expect(argv.some(arg => arg.startsWith("--uri"))).toBe(false) + expect(argv.some(arg => arg.startsWith("--password"))).toBe(false) + }) + + it("uses privileged credentials in a legacy URI for a full instance restore", async () => { + const host = restoreHost(kind) + + const result = await restore({ + ...baseConfig, + uri: "mongodb+srv://legacy:pw@cluster.example.com/shop?retryWrites=true", + backupScope: "FULL_INSTANCE", + privilegedAuth: { user: "restore-admin", password: "restore@secret" }, + } as never, "/tmp/full-instance.archive", host) + + expect(result.success).toBe(true) + expect(host.calls.tempFiles[0].content).toContain( + 'uri: "mongodb+srv://restore-admin:restore%40secret@cluster.example.com/?retryWrites=true&authSource=shop"', + ) + expect(host.calls.tempFiles[0].content).not.toContain("legacy:pw") + const argv = host.calls.spawn[0] + expect(argv.some(arg => arg.includes("legacy:pw"))).toBe(false) + expect(argv.some(arg => arg.includes("restore@secret"))).toBe(false) + }) + + it("keeps the legacy URI unchanged for a Selected Databases restore", async () => { + const host = restoreHost(kind) + const uri = "mongodb://legacy:pw@mongo.internal:27017/shop?authSource=admin" + + const result = await restore({ ...baseConfig, uri } as never, "/tmp/shop.archive", host) + + expect(result.success).toBe(true) + expect(host.calls.tempFiles).toHaveLength(0) + expect(host.calls.spawn[0]).toContain(`--uri=${uri}`) + }) + + it("uses privileged credentials for the restore command", async () => { + const host = restoreHost(kind); + + const result = await restore({ + ...baseConfig, + privilegedAuth: { user: "restore-admin", password: "restore-secret" }, + } as never, "/tmp/full-instance.archive", host); + + expect(result.success).toBe(true); + const argv = host.calls.spawn[0]; + expect(argv[argv.indexOf("--username") + 1]).toBe("restore-admin"); + expect(argv[argv.indexOf("--password") + 1]).toBe("restore-secret"); + }); + it("fails when mongorestore exits non-zero", async () => { const result = await restore(baseConfig as never, "/tmp/in.archive", restoreHost(kind, { code: 1 })); expect(result.success).toBe(false); expect(result.error).toContain("exited with code 1"); }); + + it("removes the full instance credentials file when mongorestore fails", async () => { + const host = restoreHost(kind, { code: 1 }) + + const result = await restore({ + ...baseConfig, + backupScope: "FULL_INSTANCE", + } as never, "/tmp/full-instance.archive", host) + + expect(result.success).toBe(false) + expect(host.calls.removed).toContain(host.calls.tempFiles[0].path) + }) }); describe("restoreOne()", () => { diff --git a/tests/unit/lib/jobs-route-errors.test.ts b/tests/unit/lib/jobs-route-errors.test.ts new file mode 100644 index 00000000..138f51f4 --- /dev/null +++ b/tests/unit/lib/jobs-route-errors.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { ValidationError } from "@/lib/logging/errors"; + +const mockGetAuthContext = vi.fn(); +const mockCheckPermissionWithContext = vi.fn(); +vi.mock("@/lib/auth/access-control", () => ({ + getAuthContext: (...args: unknown[]) => mockGetAuthContext(...args), + checkPermissionWithContext: (...args: unknown[]) => mockCheckPermissionWithContext(...args), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), +})); + +vi.mock("@/lib/auth/permissions", () => ({ + PERMISSIONS: { + JOBS: { WRITE: "jobs:write" }, + }, +})); + +const mockCreateJob = vi.fn(); +const mockUpdateJob = vi.fn(); +vi.mock("@/services/jobs/job-service", () => ({ + jobService: { + createJob: (...args: unknown[]) => mockCreateJob(...args), + updateJob: (...args: unknown[]) => mockUpdateJob(...args), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + default: { + systemSetting: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/logger", () => ({ + logger: { + child: () => ({ + error: vi.fn(), + }), + }, +})); + +const { POST } = await import("@/app/api/jobs/route"); +const { PUT } = await import("@/app/api/jobs/[id]/route"); + +function createPostRequest() { + return new NextRequest("http://localhost:3000/api/jobs", { + method: "POST", + body: JSON.stringify({ + name: "MongoDB full instance", + schedule: "0 0 * * *", + sourceId: "source-1", + backupScope: "FULL_INSTANCE", + destinations: [{ configId: "destination-1" }], + }), + }); +} + +function createPutRequest() { + return new NextRequest("http://localhost:3000/api/jobs/job-1", { + method: "PUT", + body: JSON.stringify({ backupScope: "FULL_INSTANCE" }), + }); +} + +function createProps() { + return { params: Promise.resolve({ id: "job-1" }) }; +} + +describe("MongoDB backup scope errors in job routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue({ userId: "user-1" }); + mockCheckPermissionWithContext.mockReturnValue(undefined); + }); + + it("returns 400 when creating a job violates backup scope validation", async () => { + mockCreateJob.mockRejectedValue( + new ValidationError("Full Instance is only supported for MongoDB jobs") + ); + + const response = await POST(createPostRequest()); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe("Full Instance is only supported for MongoDB jobs"); + }); + + it("returns 400 when updating a job violates backup scope validation", async () => { + mockUpdateJob.mockRejectedValue( + new ValidationError("Full Instance cannot be combined with directory sources") + ); + + const response = await PUT(createPutRequest(), createProps()); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe("Full Instance cannot be combined with directory sources"); + }); + + it("keeps duplicate job names as 409 when creating a job", async () => { + mockCreateJob.mockRejectedValue( + new Error('A job with the name "MongoDB full instance" already exists.') + ); + + const response = await POST(createPostRequest()); + + expect(response.status).toBe(409); + }); + + it("keeps duplicate job names as 409 when updating a job", async () => { + mockUpdateJob.mockRejectedValue( + new Error('A job with the name "MongoDB full instance" already exists.') + ); + + const response = await PUT(createPutRequest(), createProps()); + + expect(response.status).toBe(409); + }); +}); diff --git a/tests/unit/lib/storage-analyze-route.test.ts b/tests/unit/lib/storage-analyze-route.test.ts index 901df46a..86b92b5d 100644 --- a/tests/unit/lib/storage-analyze-route.test.ts +++ b/tests/unit/lib/storage-analyze-route.test.ts @@ -171,6 +171,27 @@ describe("POST /api/storage/[id]/analyze - combined (manifest v2) archives", () expect(body.directories).toBeUndefined(); }); + it("identifies a MongoDB full-instance backup from its metadata sidecar", async () => { + mockRead.mockResolvedValue(JSON.stringify({ + sourceType: "mongodb", + backupScope: "FULL_INSTANCE", + databases: { names: ["application_db"] }, + })); + + const res = await POST( + createRequest({ file: "backups/job1/mongodb.archive", type: "mongodb" }), + createProps() + ); + const body = await res.json(); + + expect(body).toEqual({ + databases: ["application_db"], + backupScope: "FULL_INSTANCE", + sourceType: "mongodb", + }); + expect(mockDownload).not.toHaveBeenCalled(); + }); + it("opens the backup with the profile the user picked after being asked", async () => { // The answer to a key prompt has to reach the server, or choosing a profile does // nothing and the same prompt comes straight back. diff --git a/tests/unit/runner/steps/02-dump.test.ts b/tests/unit/runner/steps/02-dump.test.ts index 70e86480..b9e5e464 100644 --- a/tests/unit/runner/steps/02-dump.test.ts +++ b/tests/unit/runner/steps/02-dump.test.ts @@ -158,6 +158,28 @@ describe('stepExecuteDump', () => { expect(ctx.metadata.count).toBe(3); }); + it('ignores stale selected databases when calculating Full Instance metadata', async () => { + const { resolveAdapterConfig } = await import('@/lib/adapters/config-resolver'); + (resolveAdapterConfig as ReturnType).mockResolvedValue({ + host: 'localhost', + database: 'source-default', + }); + const ctx = makeCtx(); + (ctx.job as any).source.adapterId = 'mongodb'; + (ctx.job as any).backupScope = 'FULL_INSTANCE'; + (ctx.job as any).databases = JSON.stringify(['old-selection']); + (ctx.sourceAdapter as any).getDatabases = vi.fn().mockResolvedValue(['current-a', 'current-b']); + + await stepExecuteDump(ctx); + + const dumpConfig = (ctx.sourceAdapter!.dump as ReturnType).mock.calls[0][0]; + expect(dumpConfig.backupScope).toBe('FULL_INSTANCE'); + expect(dumpConfig.database).toEqual([]); + expect(ctx.metadata.names).toEqual(['current-a', 'current-b']); + expect(ctx.metadata.count).toBe(2); + expect(ctx.metadata.label).toBe('2 DBs (fetched)'); + }); + it('handles getDatabases failure gracefully (warning logged, dump continues)', async () => { const ctx = makeCtx(); (ctx.job as any).databases = '[]'; diff --git a/tests/unit/services/job-service.test.ts b/tests/unit/services/job-service.test.ts index 2dbb2c04..10c90bb2 100644 --- a/tests/unit/services/job-service.test.ts +++ b/tests/unit/services/job-service.test.ts @@ -58,6 +58,7 @@ describe('JobService', () => { schedule: input.schedule, sourceId: input.sourceId, databases: "[]", + backupScope: "SELECTED_DATABASES", enabled: input.enabled, encryptionProfileId: null, namingTemplateId: null, diff --git a/tests/unit/services/restore-pipeline.test.ts b/tests/unit/services/restore-pipeline.test.ts index 686c3ea0..a1e786cd 100644 --- a/tests/unit/services/restore-pipeline.test.ts +++ b/tests/unit/services/restore-pipeline.test.ts @@ -7,6 +7,7 @@ import * as tarUtils from '@/lib/adapters/database/common/tar-utils'; import * as abortModule from '@/lib/execution/abort'; import { PassThrough } from 'stream'; import type { RestoreInput } from '@/services/restore/types'; +import { RESTORE_STAGES, type LogEntry } from '@/lib/core/logs'; // Hoisted so the same vi.fn() instances land in both the mock factory and test assertions. const fsMocks = vi.hoisted(() => ({ @@ -114,6 +115,13 @@ const mockSourceConfig = { updatedAt: new Date(), }; +const mockMongoSourceConfig = { + ...mockSourceConfig, + adapterId: 'mongodb', + config: JSON.stringify({ host: 'localhost', database: ['legacy-selection'] }), + name: 'MongoDB', +}; + function makeInput(overrides: Partial = {}): RestoreInput { return { storageConfigId: 'storage-1', @@ -200,6 +208,42 @@ describe('runRestorePipeline', () => { ); }); + it('assigns different staging paths to separate restore executions', async () => { + const storageAdapter = makeStorageAdapter() + const dbAdapter = makeDbAdapter() + + prismaMock.adapterConfig.findUnique + .mockResolvedValueOnce(mockStorageConfig as any) + .mockResolvedValueOnce(mockSourceConfig as any) + .mockResolvedValueOnce(mockStorageConfig as any) + .mockResolvedValueOnce(mockSourceConfig as any) + vi.mocked(registry.get) + .mockReturnValueOnce(storageAdapter as any) + .mockReturnValueOnce(dbAdapter as any) + .mockReturnValueOnce(storageAdapter as any) + .mockReturnValueOnce(dbAdapter as any) + + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(123456789) + try { + await runRestorePipeline('exec-staging-a', makeInput()) + await runRestorePipeline('exec-staging-b', makeInput()) + } finally { + nowSpy.mockRestore() + } + + const metadataPaths = storageAdapter.download.mock.calls + .filter(([, remotePath]) => remotePath.endsWith('.meta.json')) + .map(([, , localPath]) => localPath) + const archivePaths = storageAdapter.download.mock.calls + .filter(([, remotePath]) => !remotePath.endsWith('.meta.json')) + .map(([, , localPath]) => localPath) + + expect(metadataPaths).toHaveLength(2) + expect(archivePaths).toHaveLength(2) + expect(metadataPaths[0]).not.toBe(metadataPaths[1]) + expect(archivePaths[0]).not.toBe(archivePaths[1]) + }) + it('decompresses a GZIP backup when compression metadata is detected', async () => { const storageAdapter = makeStorageAdapter(); const dbAdapter = makeDbAdapter(); @@ -223,6 +267,32 @@ describe('runRestorePipeline', () => { expect(prismaMock.execution.update).toHaveBeenCalled(); }); + it('treats NONE compression metadata as an uncompressed backup', async () => { + const storageAdapter = makeStorageAdapter(); + const dbAdapter = makeDbAdapter(); + + prismaMock.adapterConfig.findUnique + .mockResolvedValueOnce(mockStorageConfig as any) + .mockResolvedValueOnce(mockSourceConfig as any); + vi.mocked(registry.get) + .mockReturnValueOnce(storageAdapter as any) + .mockReturnValueOnce(dbAdapter as any); + fsMocks.readFile.mockResolvedValueOnce(JSON.stringify({ compression: 'NONE' })); + + await runRestorePipeline('exec-no-compression', makeInput()); + + const finalUpdate = prismaMock.execution.update.mock.calls.at(-1)![0]; + const logs = JSON.parse(finalUpdate.data.logs as string) as LogEntry[]; + + expect(logs.map((entry) => entry.message)).not.toContain('Detected NONE compression.'); + expect(logs.some((entry) => entry.stage === RESTORE_STAGES.DECOMPRESSING)).toBe(false); + expect(decomp.getDecompressionStream).not.toHaveBeenCalled(); + expect(dbAdapter.restore).toHaveBeenCalledOnce(); + expect(prismaMock.execution.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ status: 'Success' }) }), + ); + }); + it('logs a warning but continues when version detection throws', async () => { const storageAdapter = makeStorageAdapter(); const dbAdapter = makeDbAdapter({ @@ -303,6 +373,101 @@ describe('runRestorePipeline', () => { expect(restoredConfig.privilegedAuth).toEqual(privilegedAuth); }); + it('restores a confirmed MongoDB Full Instance backup without database mapping', async () => { + const storageAdapter = makeStorageAdapter(); + const dbAdapter = makeDbAdapter(); + + prismaMock.adapterConfig.findUnique + .mockResolvedValueOnce(mockStorageConfig as any) + .mockResolvedValueOnce(mockMongoSourceConfig as any); + vi.mocked(registry.get) + .mockReturnValueOnce(storageAdapter as any) + .mockReturnValueOnce(dbAdapter as any); + fsMocks.readFile.mockResolvedValueOnce(JSON.stringify({ + sourceType: 'mongodb', + backupScope: 'FULL_INSTANCE', + })); + + await runRestorePipeline('exec-full-instance', makeInput({ + backupScope: 'FULL_INSTANCE', + targetDatabaseName: 'renamed', + databaseMapping: [{ originalName: 'one', targetName: 'two', selected: true }], + })); + + expect(dbAdapter.restore).toHaveBeenCalledOnce(); + const restoredConfig = dbAdapter.restore.mock.calls[0][0]; + expect(restoredConfig.backupScope).toBe('FULL_INSTANCE'); + expect(restoredConfig.databaseMapping).toBeUndefined(); + expect(restoredConfig.targetDatabaseName).toBeUndefined(); + }); + + it('fails closed when Full Instance metadata lacks an explicit Full Instance request', async () => { + const storageAdapter = makeStorageAdapter(); + const dbAdapter = makeDbAdapter(); + + prismaMock.adapterConfig.findUnique + .mockResolvedValueOnce(mockStorageConfig as any) + .mockResolvedValueOnce(mockMongoSourceConfig as any); + vi.mocked(registry.get) + .mockReturnValueOnce(storageAdapter as any) + .mockReturnValueOnce(dbAdapter as any); + fsMocks.readFile.mockResolvedValueOnce(JSON.stringify({ + sourceType: 'mongodb', + backupScope: 'FULL_INSTANCE', + })); + + await runRestorePipeline('exec-full-scope-unconfirmed', makeInput()); + + expect(dbAdapter.restore).not.toHaveBeenCalled(); + expect(prismaMock.execution.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ status: 'Failed' }) }), + ); + }); + + it('fails closed when expected Full Instance metadata cannot be downloaded', async () => { + const storageAdapter = makeStorageAdapter({ + download: vi.fn().mockResolvedValue(false), + }); + const dbAdapter = makeDbAdapter(); + + prismaMock.adapterConfig.findUnique + .mockResolvedValueOnce(mockStorageConfig as any) + .mockResolvedValueOnce(mockMongoSourceConfig as any); + vi.mocked(registry.get) + .mockReturnValueOnce(storageAdapter as any) + .mockReturnValueOnce(dbAdapter as any); + + await runRestorePipeline('exec-full-no-meta', makeInput({ backupScope: 'FULL_INSTANCE' })); + + expect(dbAdapter.restore).not.toHaveBeenCalled(); + expect(prismaMock.execution.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ status: 'Failed' }) }), + ); + }); + + it('fails closed when the sidecar does not confirm Full Instance scope', async () => { + const storageAdapter = makeStorageAdapter(); + const dbAdapter = makeDbAdapter(); + + prismaMock.adapterConfig.findUnique + .mockResolvedValueOnce(mockStorageConfig as any) + .mockResolvedValueOnce(mockMongoSourceConfig as any); + vi.mocked(registry.get) + .mockReturnValueOnce(storageAdapter as any) + .mockReturnValueOnce(dbAdapter as any); + fsMocks.readFile.mockResolvedValueOnce(JSON.stringify({ + sourceType: 'mongodb', + backupScope: 'SELECTED_DATABASES', + })); + + await runRestorePipeline('exec-full-scope-mismatch', makeInput({ backupScope: 'FULL_INSTANCE' })); + + expect(dbAdapter.restore).not.toHaveBeenCalled(); + expect(prismaMock.execution.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ status: 'Failed' }) }), + ); + }); + it('logs the multi-DB TAR manifest when archive is detected', async () => { const storageAdapter = makeStorageAdapter(); const dbAdapter = makeDbAdapter();