diff --git a/.env.example b/.env.example index a264a11..f73ac91 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ AUDIT_DB_NAME=backup_audit AUDIT_DB_USER=audit_user AUDIT_DB_PASSWORD=audit_secret -# Hetzner Storage Box (shared) +# Hetzner Storage Box (shared) — only required when a project uses storage.type: sftp HETZNER_SSH_HOST=u123456.your-storagebox.de HETZNER_SSH_USER=u123456 HETZNER_SSH_PORT=23 @@ -20,6 +20,30 @@ HETZNER_SSH_KEY_PATH=/home/node/.ssh/id_ed25519 # Restic (global defaults, overridable per project) RESTIC_PASSWORD=default-restic-repo-password +# ── Storage backend credentials ─────────────────────────────────────────────── +# Referenced from projects.yml via ${} under storage.config. Only set what you use. + +# Backblaze B2 / S3-compatible (storage.type: s3) — the recommended cloud option. +# Works for Backblaze B2, Wasabi, Cloudflare R2, MinIO, and AWS S3. +# Left uncommented because projects-example.yml references them: every ${} is resolved +# for all projects at load time, so an unset one fails the whole config load. +B2_ENDPOINT=https://s3.eu-central-003.backblazeb2.com +B2_KEY_ID=your-application-key-id +B2_APP_KEY=your-application-key + +# Backblaze B2 native API (storage.type: b2). Restic recommends the S3 API above instead. +# B2_ACCOUNT_ID=your-account-id +# B2_ACCOUNT_KEY=your-account-key + +# rclone (storage.type: rclone) — Google Drive, OneDrive, Dropbox. +# Authorize the remote on a machine with a browser, then paste it in: +# rclone authorize "drive" # on your laptop +# docker exec -it backupctl rclone config # paste the token here +# The remote name becomes the repository prefix, e.g. repository: gdrive:backups/myproject +# Defaults to /home/node/.config/rclone/rclone.conf, which the compose file mounts +# read-write from ./rclone-config — rclone rewrites refreshed OAuth tokens there. +# RCLONE_CONFIG_PATH=/home/node/.config/rclone/rclone.conf + # Global fallback notification (slack | email | webhook | none) NOTIFICATION_TYPE=slack SLACK_WEBHOOK_URL=https://hooks.slack.com/services/DEFAULT/WEBHOOK/URL @@ -60,12 +84,18 @@ LOG_MAX_FILES=5 # GPG keys directory (auto-imported on startup) GPG_KEYS_DIR=/app/gpg-keys -# Heartbeat monitoring (optional — only if using push monitors like Uptime Kuma) -# UPTIME_KUMA_BASE_URL=https://kuma.example.com +# Heartbeat monitoring — only needed if a project has a monitor block, which the +# example projects do. Drop it along with those blocks if you don't use Uptime Kuma. +UPTIME_KUMA_BASE_URL=https://kuma.example.com -# Project DB passwords (referenced in projects.yml via ${}) -VINSWARE_DB_PASSWORD=secret -VINSWARE_RESTIC_PASSWORD=restic-secret +# Project secrets (referenced in projects.yml via ${}) +# Every ${} in projects.yml is resolved at load time for all projects, including +# disabled ones — an unset variable fails the whole config load, not just that project. +VINELAB_DB_PASSWORD=secret +VINELAB_RESTIC_PASSWORD=restic-secret PROJECTX_DB_PASSWORD=secret PROJECTY_DB_PASSWORD=secret +PROJECTZ_DB_PASSWORD=secret +PROJECTZ_RESTIC_PASSWORD=restic-secret +EMAIL_PASSWORD=email-secret SMTP_PASSWORD=smtp-secret diff --git a/.gitignore b/.gitignore index 323f218..190851c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ config/projects.yml # SSH & GPG keys ssh-keys/ gpg-keys/ +rclone-config/ # Backup artifacts *.sql.gz diff --git a/CLAUDE.md b/CLAUDE.md index 3daf806..1872268 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Backup orchestration for databases, files, or both. Database-agnostic, NestJS 11 - **Entry points**: `src/main.ts` (HTTP), `src/cli.ts` (CLI via nest-commander) - **Config**: `config/projects.yml` (per-project YAML) + `.env` (global secrets/defaults) - **Audit DB**: PostgreSQL 16 via TypeORM with explicit migrations (separate container) -- **Remote storage**: Restic over SFTP to Hetzner Storage Box +- **Remote storage**: Restic, backend per project — sftp (Hetzner Storage Box), s3 (Backblaze B2/Wasabi/R2/MinIO/AWS), b2, rclone (Drive/OneDrive/Dropbox), local ## Git Workflow @@ -112,7 +112,7 @@ src/ │ │ ├── infrastructure/ # Adapters implementing ports │ │ │ ├── adapters/ │ │ │ │ ├── dumpers/ # postgres, mysql, mongo -│ │ │ │ ├── storage/ # restic + factory + tagging +│ │ │ │ ├── storage/ # restic adapter + factory + backends/ (per-type resolvers) │ │ │ │ ├── encryptors/ # gpg + key manager │ │ │ │ ├── cleanup/ # file cleanup │ │ │ │ ├── hooks/ # shell hook executor @@ -285,6 +285,8 @@ Modules may only import another module's **`application/ports/`** or **`domain/` | Ports in `application/ports/` (not `domain/`) | Ports define outbound contracts; application layer owns the orchestration interface | | `DumperRegistry` + `NotifierRegistry` | Dynamic adapter resolution by project config type | | `BackupLockPort` — file-based `.lock` | Survives crashes, visible on disk, cleaned on startup recovery | +| Storage backend per project (`storage.type`) | `ResticBackendRegistry` resolves a `StorageConfig` to a restic repository string + env. Adapter stays backend-neutral; SSH lives only in the sftp resolver | +| `/health/live` vs `/health` | Docker HEALTHCHECK uses `/health/live` (container-local). `/health` probes remote storage, cached for `HEALTH_STORAGE_CHECK_TTL_SECONDS` (default 300) — a remote outage must not restart the container | | `AuditLogPort` — `startRun`/`trackProgress`/`finishRun` | Real-time progress visibility + crash detection via orphaned records | | `FallbackWriterPort` — JSONL format | Append-only, replayed on startup. Backup success never lost to infra failure | | TypeORM entities as `*.record.ts` | Infrastructure concern, named "record" not "entity" to avoid DDD confusion | @@ -310,9 +312,10 @@ Modules may only import another module's **`application/ports/`** or **`domain/` 3. Secrets always in `.env`, referenced via `${}` in YAML 4. Missing `notification` → global `NOTIFICATION_TYPE` + config from `.env` 5. Missing `encryption` → global `ENCRYPTION_ENABLED` / `ENCRYPTION_TYPE` / `GPG_RECIPIENT` -6. Missing `restic.password` → global `RESTIC_PASSWORD` -7. `compression.enabled` defaults to `true` (always compress) -8. Config changes require explicit `backupctl config reload` — no hot-reload +6. Missing `storage.password` → global `RESTIC_PASSWORD` +7. Legacy `restic:` block → read as `storage:` with `type: sftp` (deprecated, warns on load). Both keys present is a validation error +8. `compression.enabled` defaults to `true` (always compress) +9. Config changes require explicit `backupctl config reload` — no hot-reload ## Tech Stack @@ -328,7 +331,7 @@ Modules may only import another module's **`application/ports/`** or **`domain/` | Logging | Winston (nest-winston) with rotation | | Testing | Jest | | Container | Docker + Docker Compose | -| Remote storage | Restic → Hetzner Storage Box (SFTP) | +| Remote storage | Restic → sftp / s3 / b2 / rclone / local | | Encryption | GPG | ## Development Commands @@ -364,7 +367,7 @@ scripts/backupctl-manage.sh check # validate prerequisites # Inside container docker exec backupctl node dist/cli.js health -docker exec backupctl node dist/cli.js run vinsware --dry-run +docker exec backupctl node dist/cli.js run vinelab --dry-run # Migrations — dev (manual via scripts/dev.sh) scripts/dev.sh migrate:run # run pending @@ -574,7 +577,7 @@ Explain **why**, not obvious **what**. No comments on self-evident code. |---------|-------------| | `run [--all] [--dry-run]` | Trigger backup or simulate | | `status [project] [--last n]` | Backup status (shows current_stage) | -| `health` | Audit DB, restic repos, disk (`HEALTH_DISK_MIN_FREE_GB`), SSH | +| `health` | Audit DB, disk (`HEALTH_DISK_MIN_FREE_GB`), per-project storage reachability | | `restore [--only db/assets] [--decompress] [--guide]` | Restore + guidance | | `snapshots [--last n]` | List snapshots with tags | | `prune / --all` | Manual restic prune | @@ -599,10 +602,10 @@ Explain **why**, not obvious **what**. No comments on self-evident code. ## Docker Two containers via `docker-compose.yml`: -- `backupctl` — Node.js 20 Alpine + database clients + restic + GPG +- `backupctl` — Node.js 20 Alpine + database clients + restic + rclone + GPG - `backupctl-audit-db` — PostgreSQL 16 Alpine -Volumes: `${BACKUP_BASE_DIR}`, `./config:ro`, `./ssh-keys:ro`, `./gpg-keys:ro`, asset paths +Volumes: `${BACKUP_BASE_DIR}`, `./config:ro`, `./ssh-keys:ro`, `./gpg-keys:ro`, `./rclone-config` (read-write — rclone rewrites refreshed OAuth tokens), asset paths Host scripts: `scripts/backupctl-manage.sh` (prod), `scripts/dev.sh` (dev) @@ -611,6 +614,7 @@ Host scripts: `scripts/backupctl-manage.sh` (prod), `scripts/dev.sh` (dev) - `.env` (secrets) - `ssh-keys/` (SSH private keys) - `gpg-keys/` +- `rclone-config/` (rclone OAuth tokens) - `node_modules/`, `dist/` - `*.sql.gz`, `*.gpg` (backup artifacts) - `*.lock` (backup lock files) diff --git a/Dockerfile b/Dockerfile index 77dd140..dcfaf34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,6 +57,7 @@ RUN apk upgrade --no-cache \ openssh-client \ gnupg \ fuse3 \ + rclone \ docker-cli \ curl \ tini \ @@ -71,9 +72,10 @@ RUN mkdir -p /data/backups/.logs /data/backups/.fallback-audit \ && chown -R node:node /data/backups # Run as non-root — SSH keys mounted to /home/node/.ssh -RUN mkdir -p /home/node/.ssh /home/node/.gnupg \ - && chmod 700 /home/node/.gnupg \ - && chown -R node:node /home/node/.ssh /home/node/.gnupg +# rclone rewrites refreshed OAuth tokens into its config, so .config/rclone must stay writable. +RUN mkdir -p /home/node/.ssh /home/node/.gnupg /home/node/.config/rclone \ + && chmod 700 /home/node/.gnupg /home/node/.config/rclone \ + && chown -R node:node /home/node/.ssh /home/node/.gnupg /home/node/.config WORKDIR /app ENV NODE_ENV=production @@ -86,7 +88,9 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh \ USER node EXPOSE 3100 +# /health/live is container-local only. /health additionally probes remote storage, +# which must not restart the container when a cloud backend has an outage. HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ - CMD curl -f http://localhost:${APP_PORT:-3100}/health || exit 1 + CMD curl -f http://localhost:${APP_PORT:-3100}/health/live || exit 1 ENTRYPOINT ["/sbin/tini", "--", "docker-entrypoint.sh"] CMD ["node", "dist/main.js"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 0588bcd..7ab36b0 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -27,14 +27,16 @@ RUN apk upgrade --no-cache \ openssh-client \ gnupg \ fuse3 \ + rclone \ curl COPY --from=restic-builder /restic /usr/local/bin/restic # Run as non-root — SSH keys mounted to /home/node/.ssh -RUN mkdir -p /home/node/.ssh /home/node/.gnupg \ - && chmod 700 /home/node/.gnupg \ - && chown -R node:node /home/node/.ssh /home/node/.gnupg +# rclone rewrites refreshed OAuth tokens into its config, so .config/rclone must stay writable. +RUN mkdir -p /home/node/.ssh /home/node/.gnupg /home/node/.config/rclone \ + && chmod 700 /home/node/.gnupg /home/node/.config/rclone \ + && chown -R node:node /home/node/.ssh /home/node/.gnupg /home/node/.config WORKDIR /app diff --git a/_docs/monitoring/uptime-kuma-prd/prd.md b/_docs/monitoring/uptime-kuma-prd/prd.md index 0bafed2..cda1686 100644 --- a/_docs/monitoring/uptime-kuma-prd/prd.md +++ b/_docs/monitoring/uptime-kuma-prd/prd.md @@ -52,7 +52,7 @@ This creates a blind spot: the absence of a backup going undetected until someon 2. Run `docker compose up -d` — Kuma container starts alongside existing containers 3. Access Kuma UI at the configured port (default `3001`) 4. Complete Kuma's first-time setup (create admin account) -5. Create a Push Monitor for each backup project (e.g., "vinsware-backup"), set heartbeat interval to match the project's cron schedule + grace period +5. Create a Push Monitor for each backup project (e.g., "vinelab-backup"), set heartbeat interval to match the project's cron schedule + grace period 6. Copy the push token from each monitor into `projects.yml` under `monitor.config.push_token` 7. Run `backupctl config validate` to verify the monitor config is valid @@ -174,7 +174,7 @@ This creates a blind spot: the absence of a backup going undetected until someon - FR-5.2: No HTTP call is made to Kuma during dry runs **Acceptance Criteria:** -- AC-5.1: Running `backupctl run vinsware --dry-run` with monitor config does NOT send a heartbeat to Kuma +- AC-5.1: Running `backupctl run vinelab --dry-run` with monitor config does NOT send a heartbeat to Kuma - AC-5.2: Kuma's heartbeat timer is unaffected by dry runs **Dependencies:** None @@ -224,14 +224,14 @@ UPTIME_KUMA_PORT=3001 ```yaml projects: - - name: vinsware + - name: vinelab enabled: true cron: '0 0 * * *' # ... existing config ... notification: type: slack config: - webhook_url: https://hooks.slack.com/services/VINSWARE/SPECIFIC/HOOK + webhook_url: https://hooks.slack.com/services/VINELAB/SPECIFIC/HOOK monitor: type: uptime-kuma config: diff --git a/_docs/monitoring/uptime-kuma-prd/setup-guide.md b/_docs/monitoring/uptime-kuma-prd/setup-guide.md index 93e4c64..7f18c17 100644 --- a/_docs/monitoring/uptime-kuma-prd/setup-guide.md +++ b/_docs/monitoring/uptime-kuma-prd/setup-guide.md @@ -112,7 +112,7 @@ For each backup project that should report heartbeats: 1. In Kuma UI, click **Add New Monitor** 2. Set **Monitor Type** to **Push** -3. Set a descriptive **Friendly Name** (e.g., `vinsware-backup`) +3. Set a descriptive **Friendly Name** (e.g., `vinelab-backup`) 4. Set **Heartbeat Interval** to match the project's cron schedule: | Cron Schedule | Description | Interval | Grace Period | @@ -221,7 +221,7 @@ For each project, add a `monitor` block with the push token from step 5: ```yaml projects: - - name: vinsware + - name: vinelab enabled: true cron: '0 0 * * *' # ... existing database, assets, restic, notification config ... @@ -248,10 +248,10 @@ This verifies that `UPTIME_KUMA_BASE_URL` is set when any project has a `monitor Trigger a backup and confirm the heartbeat reaches Kuma: ```bash -backupctl run vinsware +backupctl run vinelab ``` -In the Kuma dashboard, the `vinsware-backup` monitor should show a green UP status with the backup duration displayed as response time. +In the Kuma dashboard, the `vinelab-backup` monitor should show a green UP status with the backup duration displayed as response time. To test failure detection, stop sending heartbeats (or run a backup that fails). After the configured interval + grace period, Kuma will mark the monitor as DOWN and send alerts through its notification channels. diff --git a/config/projects-example.yml b/config/projects-example.yml index 4f70932..aeb4185 100644 --- a/config/projects-example.yml +++ b/config/projects-example.yml @@ -1,22 +1,24 @@ projects: - - name: vinsware + - name: vinelab enabled: true cron: '0 0 * * *' timeout_minutes: 30 database: type: postgres - host: postgres-vinsware + host: postgres-vinelab port: 5432 - name: vinsware_db + name: vinelab_db user: backup_user - password: ${VINSWARE_DB_PASSWORD} + password: ${VINELAB_DB_PASSWORD} assets: paths: - - /data/vinsware/uploads - - /data/vinsware/assets - restic: - repository_path: /backups/vinsware - password: ${VINSWARE_RESTIC_PASSWORD} + - /data/vinelab/uploads + - /data/vinelab/assets + # Hetzner Storage Box over SSH. Credentials come from the HETZNER_SSH_* vars in .env. + storage: + type: sftp + repository: /backups/vinelab + password: ${VINELAB_RESTIC_PASSWORD} snapshot_mode: combined retention: local_days: 7 @@ -26,20 +28,20 @@ projects: encryption: enabled: true type: gpg - recipient: vineeth.nk@vinsware.com + recipient: vineeth.nk@vinelab.in hooks: - pre_backup: 'curl -s http://vinsware-app:3000/maintenance/on' - post_backup: 'curl -s http://vinsware-app:3000/maintenance/off' + pre_backup: 'curl -s http://vinelab-app:3000/maintenance/on' + post_backup: 'curl -s http://vinelab-app:3000/maintenance/off' verification: enabled: true notification: type: slack config: - webhook_url: https://hooks.slack.com/services/VINSWARE/SPECIFIC/HOOK + webhook_url: https://hooks.slack.com/services/VINELAB/SPECIFIC/HOOK monitor: type: uptime-kuma config: - push_token: YOUR_VINSWARE_PUSH_TOKEN + push_token: YOUR_VINELAB_PUSH_TOKEN - name: project-x enabled: true @@ -54,9 +56,19 @@ projects: assets: paths: - /data/projectx/storage - restic: - repository_path: /backups/project-x + # Backblaze B2 via its S3-compatible API — restic recommends this over the native + # b2: backend. The same block works for Wasabi, Cloudflare R2, MinIO and AWS S3: + # only the endpoint changes. `repository` is /. + # Omitting `password` falls back to RESTIC_PASSWORD in .env. + storage: + type: s3 + repository: my-backup-bucket/project-x snapshot_mode: separate + config: + endpoint: ${B2_ENDPOINT} + access_key_id: ${B2_KEY_ID} + secret_access_key: ${B2_APP_KEY} + # region: eu-central-1 # only needed by providers that require it retention: local_days: 14 keep_daily: 14 @@ -87,6 +99,8 @@ projects: password: ${PROJECTY_DB_PASSWORD} assets: paths: [] + # DEPRECATED shape, still accepted: a bare `restic:` block is read as + # `storage: { type: sftp }` and logs a warning. Prefer `storage:` as above. restic: repository_path: /backups/project-y snapshot_mode: combined @@ -94,3 +108,31 @@ projects: local_days: 7 keep_daily: 7 keep_weekly: 4 + + # Google Drive / OneDrive / Dropbox via rclone. Authorize the remote first: + # rclone authorize "drive" # on a machine with a browser + # docker exec -it backupctl rclone config # paste the token in here + # `repository` is :. Consumer cloud APIs are rate-limited and + # slow for restic's many small pack files — fine for modest data, but prefer s3 for + # anything large. + - name: project-z + enabled: false + cron: '0 4 * * *' + database: + type: postgres + host: postgres-projectz + port: 5432 + name: projectz_db + user: backup_user + password: ${PROJECTZ_DB_PASSWORD} + assets: + paths: [] + storage: + type: rclone + repository: gdrive:backups/project-z + password: ${PROJECTZ_RESTIC_PASSWORD} + snapshot_mode: combined + retention: + local_days: 7 + keep_daily: 7 + keep_weekly: 4 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d07b9a8..2059da2 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -20,6 +20,8 @@ services: - ./test:/app/test - ./ssh-keys:/home/node/.ssh:ro - ./gpg-keys:/app/gpg-keys:ro + # Deliberately writable: rclone rewrites refreshed OAuth tokens into this file. + - ./rclone-config:/home/node/.config/rclone - ./eslint.config.mjs:/app/eslint.config.mjs:ro - ./knip.json:/app/knip.json:ro - ./.jscpd.json:/app/.jscpd.json:ro diff --git a/docker-compose.yml b/docker-compose.yml index 1f83801..ded9175 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: - ./config:/app/config:ro - ./ssh-keys:/home/node/.ssh:ro - ./gpg-keys:/app/gpg-keys:ro + # Deliberately writable: rclone rewrites refreshed OAuth tokens into this file. + - ./rclone-config:/home/node/.config/rclone - /var/run/docker.sock:/var/run/docker.sock:ro group_add: - '${DOCKER_GID:-999}' diff --git a/docs/03-requirements.md b/docs/03-requirements.md index b880e75..36bea79 100644 --- a/docs/03-requirements.md +++ b/docs/03-requirements.md @@ -187,15 +187,15 @@ The `BackupLog` entity tracks every backup run: ### Backup Started ``` -🔄 Backup started — vinsware +🔄 Backup started — vinelab Time: 2026-03-18 00:00:00 IST ``` ### Backup Success ``` -✅ Backup completed — vinsware -DB: vinsware_db | Dump: 245 MB | Encrypted: Yes | Verified: Yes +✅ Backup completed — vinelab +DB: vinelab_db | Dump: 245 MB | Encrypted: Yes | Verified: Yes Snapshot: a1b2c3d4 | Mode: combined New files: 12 | Changed: 3 | Added: 52 MB Pruned: 2 snapshots | Local cleaned: 1 file @@ -205,17 +205,17 @@ Duration: 3m 12s ### Backup Failed ``` -❌ Backup failed — vinsware +❌ Backup failed — vinelab Stage: restic sync | Retry: 3/3 Error: connection timeout to storage box -Dump file: /data/backups/vinsware/backup_2026-03-18_000000.sql.gz +Dump file: /data/backups/vinelab/backup_2026-03-18_000000.sql.gz Duration: 5m 42s ``` ### Backup Timeout Warning ``` -⚠️ Backup timeout warning — vinsware +⚠️ Backup timeout warning — vinelab Elapsed: 35m | Timeout threshold: 30m Current stage: restic sync Backup is still running — this is a warning, not a failure. @@ -226,7 +226,7 @@ Backup is still running — this is a warning, not a failure. ``` 📊 Daily Backup Summary — 2026-03-18 -✅ vinsware — 245 MB — 3m 12s — a1b2c3d4 +✅ vinelab — 245 MB — 3m 12s — a1b2c3d4 ✅ project-x — 128 MB — 1m 45s — e5f6g7h8 ❌ project-y — FAILED — restic sync timeout @@ -240,11 +240,11 @@ The webhook notifier POSTs `application/json` with a `text` field containing the ```json { "event": "backup_success", - "project": "vinsware", - "text": "✅ Backup completed — vinsware\nDB: vinsware_db | Dump: 245 MB ...", + "project": "vinelab", + "text": "✅ Backup completed — vinelab\nDB: vinelab_db | Dump: 245 MB ...", "data": { "run_id": "uuid", - "project_name": "vinsware", + "project_name": "vinelab", "status": "success", "snapshot_id": "a1b2c3d4", "dump_size_bytes": 257949696, diff --git a/docs/04-installation.md b/docs/04-installation.md index d994f22..464d93e 100644 --- a/docs/04-installation.md +++ b/docs/04-installation.md @@ -197,26 +197,26 @@ An interactive loop that builds `config/projects.yml` one project at a time. [9/14] Project configuration Add a project? [Y/n]: Y - Project name: vinsware - Docker network (empty = host/default): vinsware_vinsware-network + Project name: vinelab + Docker network (empty = host/default): vinelab_vinelab-network Database type (postgres|mysql|mongodb): postgres - Database host: postgres-vinsware + Database host: postgres-vinelab Database port [5432]: - Database name: vinsware_db + Database name: vinelab_db Database user: backup_user Database password: ******** Cron schedule [0 2 * * *]: 0 0 * * * Timeout minutes (blank for none): 30 - Restic repo path [/backups/vinsware]: + Restic repo path [/backups/vinelab]: Restic password (blank for global): Snapshot mode (combined|separate) [combined]: - Asset paths (comma-separated, blank for none): /data/vinsware/uploads, /data/vinsware/assets + Asset paths (comma-separated, blank for none): /data/vinelab/uploads, /data/vinelab/assets Enable verification? [Y/n]: - Pre-backup hook (blank for none): curl -s http://vinsware-app:3000/maintenance/on - Post-backup hook (blank for none): curl -s http://vinsware-app:3000/maintenance/off + Pre-backup hook (blank for none): curl -s http://vinelab-app:3000/maintenance/on + Post-backup hook (blank for none): curl -s http://vinelab-app:3000/maintenance/off Notification override? [y/N]: - ✓ vinsware added. + ✓ vinelab added. Add another project? [Y/n]: N ``` @@ -229,7 +229,7 @@ Generates `.env` and `config/projects.yml` from all collected values. [10/14] Generating configuration files ✓ .env created ✓ config/projects.yml created - ✓ Directories created: config/, ssh-keys/, gpg-keys/ + ✓ Directories created: config/, ssh-keys/, gpg-keys/, rclone-config/ ``` ### 11. Build and Deploy @@ -244,10 +244,10 @@ Optionally builds the Docker image, starts containers, runs migrations, initiali Starting containers... done. Waiting for audit DB... ready. Running migrations... done. - Initializing restic repo for vinsware... done. + Initializing restic repo for vinelab... done. Running health check... ✓ Audit DB: connected - ✓ Restic (vinsware): accessible + ✓ Restic (vinelab): accessible ✓ Disk: 42 GB free (threshold: 5 GB) ✓ SSH: connected ``` @@ -274,7 +274,7 @@ After installation: ```bash backupctl health # instead of: docker exec backupctl node dist/cli.js health -backupctl run vinsware --dry-run # instead of: docker exec backupctl node dist/cli.js run vinsware --dry-run +backupctl run vinelab --dry-run # instead of: docker exec backupctl node dist/cli.js run vinelab --dry-run backupctl-dev health # instead of: scripts/dev.sh cli health ``` @@ -295,7 +295,7 @@ You can also install shortcuts separately at any time: ```bash mkdir backupctl && cd backupctl -mkdir -p config ssh-keys gpg-keys +mkdir -p config ssh-keys gpg-keys rclone-config ``` **Or from source:** @@ -303,7 +303,7 @@ mkdir -p config ssh-keys gpg-keys ```bash git clone https://github.com/vineethkrishnan/backupctl.git && cd backupctl npm ci -mkdir -p config ssh-keys gpg-keys +mkdir -p config ssh-keys gpg-keys rclone-config ``` ### 2. Create docker-compose.yml @@ -325,6 +325,9 @@ services: - ./config:/app/config:ro - ./ssh-keys:/home/node/.ssh:ro - ./gpg-keys:/app/gpg-keys:ro + # Deliberately writable, unlike the mounts above: rclone rewrites refreshed + # OAuth tokens into its config. Only needed for storage.type: rclone. + - ./rclone-config:/home/node/.config/rclone - /var/run/docker.sock:/var/run/docker.sock:ro group_add: - '${DOCKER_GID:-999}' @@ -396,7 +399,7 @@ HETZNER_SSH_USER=u123456 RESTIC_PASSWORD= # At least one project DB password -VINSWARE_DB_PASSWORD= +VINELAB_DB_PASSWORD= ``` All other variables have sensible defaults. See [Configuration](05-configuration.md) for the full reference. @@ -407,21 +410,22 @@ Create `config/projects.yml` with at least one project: ```yaml projects: - - name: vinsware + - name: vinelab enabled: true cron: "0 2 * * *" - docker_network: vinsware_vinsware-network # optional — Docker network where DB lives + docker_network: vinelab_vinelab-network # optional — Docker network where DB lives database: type: postgres - host: postgres-vinsware + host: postgres-vinelab port: 5432 - name: vinsware_db + name: vinelab_db user: backup_user - password: ${VINSWARE_DB_PASSWORD} + password: ${VINELAB_DB_PASSWORD} - restic: - repository_path: backups/vinsware + storage: + type: sftp + repository: backups/vinelab snapshot_mode: combined retention: @@ -518,25 +522,25 @@ docker exec -i backupctl sftp -i /home/node/.ssh/id_ed25519 \ -P 23 -o StrictHostKeyChecking=accept-new \ u123456@u123456.your-storagebox.de <<'EOF' mkdir backups -mkdir backups/vinsware +mkdir backups/vinelab bye EOF ``` ::: tip -Hetzner Storage Box paths must be **relative** (e.g., `backups/vinsware`, not `/backups/vinsware`). The storage box chroots to the user's home directory, so `/backups` refers to a read-only system path. +Hetzner Storage Box paths must be **relative** (e.g., `backups/vinelab`, not `/backups/vinelab`). The storage box chroots to the user's home directory, so `/backups` refers to a read-only system path. ::: **Step 2 — Initialize the restic repository:** ```bash -docker exec backupctl node dist/cli.js restic vinsware init +docker exec backupctl node dist/cli.js restic vinelab init ``` Expected output: ``` -created restic repository at sftp:u123456@u123456.your-storagebox.de:backups/vinsware +created restic repository at sftp:u123456@u123456.your-storagebox.de:backups/vinelab Please note that knowledge of your password is required to access the repository. Losing your password means that your data is @@ -558,7 +562,7 @@ Expected output: ``` Health Check Results: Audit DB: ✓ connected - Restic (vinsware): ✓ accessible + Restic (vinelab): ✓ accessible Disk space: ✓ 42 GB free (threshold: 5 GB) SSH: ✓ connected to u123456.your-storagebox.de ``` @@ -568,7 +572,7 @@ Health Check Results: Run a dry-run backup to validate the full configuration without executing any destructive operations: ```bash -docker exec backupctl node dist/cli.js run vinsware --dry-run +docker exec backupctl node dist/cli.js run vinelab --dry-run ``` This validates config loading, database connectivity, restic repo access, SSH connectivity, disk space, and GPG key availability (if encryption is enabled). @@ -579,16 +583,16 @@ After installation is complete, verify the system end-to-end: ```bash # Trigger a real backup -docker exec backupctl node dist/cli.js run vinsware +docker exec backupctl node dist/cli.js run vinelab # Check backup status -docker exec backupctl node dist/cli.js status vinsware +docker exec backupctl node dist/cli.js status vinelab # View audit logs -docker exec backupctl node dist/cli.js logs vinsware --last 1 +docker exec backupctl node dist/cli.js logs vinelab --last 1 # Confirm snapshots exist on remote storage -docker exec backupctl node dist/cli.js snapshots vinsware --last 1 +docker exec backupctl node dist/cli.js snapshots vinelab --last 1 ``` The cron scheduler starts automatically when the container boots. Backups will run on their configured schedules without further intervention. diff --git a/docs/05-configuration.md b/docs/05-configuration.md index 380ed76..670dab5 100644 --- a/docs/05-configuration.md +++ b/docs/05-configuration.md @@ -31,13 +31,26 @@ Most configuration changes in `projects.yml` take effect automatically on the ne ### Hetzner Storage Box +Only required when a project uses `storage.type: sftp`. An S3-only or rclone-only deployment can leave these unset. + | Variable | Default | Description | |----------|---------|-------------| -| `HETZNER_SSH_HOST` | — | **Required.** Storage box hostname (e.g., `u123456.your-storagebox.de`) | -| `HETZNER_SSH_USER` | — | **Required.** Storage box SSH user (e.g., `u123456`) | +| `HETZNER_SSH_HOST` | — | Storage box hostname (e.g., `u123456.your-storagebox.de`) | +| `HETZNER_SSH_USER` | — | Storage box SSH user (e.g., `u123456`) | | `HETZNER_SSH_PORT` | `23` | SSH port for the storage box | | `HETZNER_SSH_KEY_PATH` | `/home/node/.ssh/id_ed25519` | Path to SSH private key inside the container | +### Storage Backend Credentials + +Referenced from `projects.yml` via `${}` under `storage.config`. Only set what you use. See [Storage](#storage) for the full backend list. + +| Variable | Default | Description | +|----------|---------|-------------| +| `B2_ENDPOINT` | — | S3 endpoint, e.g. `https://s3.eu-central-003.backblazeb2.com` | +| `B2_KEY_ID` | — | S3 access key ID | +| `B2_APP_KEY` | — | S3 secret access key | +| `RCLONE_CONFIG_PATH` | rclone's default (`/home/node/.config/rclone/rclone.conf`) | Path to the rclone config file. Must be writable — rclone rewrites refreshed OAuth tokens into it | + ### Restic | Variable | Default | Description | @@ -90,6 +103,7 @@ These are used when a project has no `encryption` block in YAML. | Variable | Default | Description | |----------|---------|-------------| | `HEALTH_DISK_MIN_FREE_GB` | `5` | Minimum free disk space in GB before health check fails | +| `HEALTH_STORAGE_CHECK_TTL_SECONDS` | `300` | How long `/health` caches its per-project storage reachability probe. Each probe is a real restic round trip, so lowering this increases API calls (and cost) against billed backends like B2 | ### Daily Summary @@ -102,13 +116,13 @@ These are used when a project has no `encryption` block in YAML. Project-specific secrets follow the naming pattern `{PROJECT}_DB_PASSWORD` and `{PROJECT}_RESTIC_PASSWORD` (uppercase project name with hyphens replaced by underscores): ```env -VINSWARE_DB_PASSWORD=secret -VINSWARE_RESTIC_PASSWORD=restic-secret +VINELAB_DB_PASSWORD=secret +VINELAB_RESTIC_PASSWORD=restic-secret PROJECTX_DB_PASSWORD=secret PROJECTY_DB_PASSWORD=secret ``` -These are referenced in `projects.yml` via `${VINSWARE_DB_PASSWORD}`. +These are referenced in `projects.yml` via `${VINELAB_DB_PASSWORD}`. ## Project Configuration (projects.yml) @@ -155,13 +169,92 @@ Only applies when `database` is configured. |-------|------|----------|---------|-------------| | `assets.paths` | string[] | no | `[]` | Filesystem paths to include in the backup alongside the database dump (or as the sole backup target for files-only projects). Paths that don't exist at backup time are skipped with a warning | -### Restic +### Storage + +Each project chooses its own backend. Whatever the backend, restic encrypts the repository, so the provider never sees plaintext. | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `restic.repository_path` | string | yes | — | Path on the Hetzner Storage Box for this project's restic repo. **Must be relative** (e.g., `backups/myproject`, not `/backups/myproject`) — Hetzner Storage Box chroots to the user home directory | -| `restic.password` | string | no | `RESTIC_PASSWORD` from `.env` | Per-project restic repo password | -| `restic.snapshot_mode` | string | no | `combined` | `combined` (one snapshot for dump + assets) or `separate` (individual snapshots) | +| `storage.type` | string | no | `sftp` | One of `sftp`, `s3`, `b2`, `rclone`, `local` | +| `storage.repository` | string | yes | — | Repository location. Meaning depends on `type` — see the table below | +| `storage.password` | string | no | `RESTIC_PASSWORD` from `.env` | Per-project restic repo password | +| `storage.snapshot_mode` | string | no | `combined` | `combined` (one snapshot for dump + assets) or `separate` (individual snapshots) | +| `storage.config` | map | depends | `{}` | Per-backend credentials. Required keys depend on `type` | + +#### Backends + +| `type` | `repository` format | Required `config` keys | Notes | +|--------|--------------------|------------------------|-------| +| `sftp` | `backups/myproject` | none — uses `HETZNER_SSH_*` from `.env` | **Must be relative** — Hetzner Storage Box chroots to the user home directory | +| `s3` | `/` | `endpoint`, `access_key_id`, `secret_access_key`. Optional `region` | Backblaze B2, Wasabi, Cloudflare R2, MinIO, AWS S3 | +| `b2` | `:` | `account_id`, `account_key` | B2's native API. Restic recommends using `s3` against B2 instead | +| `rclone` | `:` | none. Optional `config_path` | Google Drive, OneDrive, Dropbox. Requires an authorized rclone remote | +| `local` | `/srv/restic-repo` | none | A path inside the container. Useful for testing or a mounted disk | + +`config validate` checks the required keys for the chosen type, so a missing credential is caught before a backup runs rather than at 2am. + +#### Backblaze B2 / S3-compatible + +The recommended cloud backend. One block covers every S3-compatible provider; only the endpoint changes. + +```yaml +storage: + type: s3 + repository: my-backup-bucket/myproject + password: ${MYPROJECT_RESTIC_PASSWORD} + snapshot_mode: combined + config: + endpoint: ${B2_ENDPOINT} # https://s3.eu-central-003.backblazeb2.com + access_key_id: ${B2_KEY_ID} + secret_access_key: ${B2_APP_KEY} +``` + +#### Google Drive, OneDrive, Dropbox (rclone) + +These go through rclone, which needs an authorized remote. OAuth needs a browser, so authorize on your laptop and paste the token into the container: + +```bash +rclone authorize "drive" # on a machine with a browser +docker exec -it backupctl rclone config # paste the token here +``` + +The remote name becomes the repository prefix: + +```yaml +storage: + type: rclone + repository: gdrive:backups/myproject + password: ${MYPROJECT_RESTIC_PASSWORD} +``` + +rclone's config lives at `/home/node/.config/rclone/rclone.conf`, mounted read-write from `./rclone-config`. That mount **must stay writable**: rclone rewrites refreshed OAuth tokens into it, and a read-only mount loses them on every container recreate, surfacing later as an expired-token failure. + +Worth knowing before you pick these: a restic repository is thousands of small pack files, and consumer Drive/OneDrive/Dropbox APIs are rate-limited and slow for that access pattern. Fine for modest data; prefer `s3` for anything large. Automated bulk storage on consumer tiers is also a grey area in those providers' terms. + +::: warning iCloud is not supported +rclone's iCloud Drive backend is experimental, rejects app-specific passwords, and needs interactive 2FA with a trust token that expires every 30 days — which breaks unattended cron backups and would put your primary Apple ID password in `.env`. +::: + +#### Deprecated: the `restic:` block + +The old SFTP-only block still works and is read as `storage:` with `type: sftp`, logging a warning on load. Migrate by renaming the key and `repository_path` → `repository`: + +```yaml +# Before +restic: + repository_path: backups/myproject + password: ${MYPROJECT_RESTIC_PASSWORD} + snapshot_mode: combined + +# After +storage: + type: sftp + repository: backups/myproject + password: ${MYPROJECT_RESTIC_PASSWORD} + snapshot_mode: combined +``` + +Specifying both `restic:` and `storage:` is a validation error. ### Retention @@ -256,7 +349,7 @@ Times are interpreted in the timezone set by the `TIMEZONE` environment variable A single restic snapshot contains both the database dump and all asset paths. Tagged with `backupctl:combined,project:{name}`. ```yaml -restic: +storage: snapshot_mode: combined ``` @@ -269,7 +362,7 @@ Cons: any change in assets triggers a new snapshot even if the DB hasn't changed Individual restic snapshots for the database dump and each asset path. The dump snapshot is tagged `backupctl:db,project:{name}`. Each asset snapshot is tagged `backupctl:assets:{path},project:{name}`. ```yaml -restic: +storage: snapshot_mode: separate ``` @@ -323,7 +416,7 @@ The webhook notifier POSTs `application/json` with an `event` field, a `text` fi encryption: enabled: true type: gpg - recipient: vinsware-backup@company.com + recipient: vinelab-backup@company.com ``` ### Global (via .env) @@ -345,28 +438,29 @@ A full `config/projects.yml` with three projects using different databases and s ```yaml projects: # PostgreSQL with full configuration - - name: vinsware + - name: vinelab enabled: true cron: "0 0 * * *" timeout_minutes: 30 - docker_network: vinsware_vinsware-network + docker_network: vinelab_vinelab-network database: type: postgres - host: postgres-vinsware + host: postgres-vinelab port: 5432 - name: vinsware_db + name: vinelab_db user: backup_user - password: ${VINSWARE_DB_PASSWORD} + password: ${VINELAB_DB_PASSWORD} assets: paths: - - /data/vinsware/uploads - - /data/vinsware/assets + - /data/vinelab/uploads + - /data/vinelab/assets - restic: - repository_path: backups/vinsware - password: ${VINSWARE_RESTIC_PASSWORD} + storage: + type: sftp + repository: backups/vinelab + password: ${VINELAB_RESTIC_PASSWORD} snapshot_mode: combined retention: @@ -378,11 +472,11 @@ projects: encryption: enabled: true type: gpg - recipient: vinsware-backup@company.com + recipient: vinelab-backup@company.com hooks: - pre_backup: "curl -s http://vinsware-app:3000/maintenance/on" - post_backup: "curl -s http://vinsware-app:3000/maintenance/off" + pre_backup: "curl -s http://vinelab-app:3000/maintenance/on" + post_backup: "curl -s http://vinelab-app:3000/maintenance/off" verification: enabled: true @@ -390,12 +484,12 @@ projects: notification: type: slack config: - webhook_url: https://hooks.slack.com/services/VINSWARE/SPECIFIC/HOOK + webhook_url: https://hooks.slack.com/services/VINELAB/SPECIFIC/HOOK monitor: type: uptime-kuma config: - push_token: YOUR_VINSWARE_PUSH_TOKEN + push_token: YOUR_VINELAB_PUSH_TOKEN # MySQL with email notifications and separate snapshots - name: project-x @@ -414,8 +508,9 @@ projects: paths: - /data/projectx/storage - restic: - repository_path: backups/project-x + storage: + type: sftp + repository: backups/project-x snapshot_mode: separate retention: @@ -449,8 +544,9 @@ projects: assets: paths: [] - restic: - repository_path: backups/analytics + storage: + type: sftp + repository: backups/analytics snapshot_mode: combined retention: @@ -471,8 +567,9 @@ projects: - /data/static/uploads - /data/static/media - restic: - repository_path: backups/static-assets + storage: + type: sftp + repository: backups/static-assets snapshot_mode: combined retention: @@ -487,10 +584,10 @@ backupctl organizes all data under `BACKUP_BASE_DIR` (default `/data/backups`): ``` ${BACKUP_BASE_DIR}/ -├── vinsware/ -│ ├── vinsware_backup_20260318_000000_a1b2.sql.gz # compressed dump -│ ├── vinsware_backup_20260318_000000_a1b2.sql.gz.gpg # encrypted dump (if enabled) -│ ├── vinsware_backup_20260317_000000_c3d4.sql.gz +├── vinelab/ +│ ├── vinelab_backup_20260318_000000_a1b2.sql.gz # compressed dump +│ ├── vinelab_backup_20260318_000000_a1b2.sql.gz.gpg # encrypted dump (if enabled) +│ ├── vinelab_backup_20260317_000000_c3d4.sql.gz │ └── .lock # present while backup is running ├── project-x/ │ ├── project-x_backup_20260318_013000_e5f6.sql.gz @@ -515,7 +612,7 @@ Use the CLI to validate configuration at any time: backupctl config validate # Show resolved config for a specific project (secrets masked) -backupctl config show vinsware +backupctl config show vinelab ``` `config validate` checks: diff --git a/docs/06-cli-reference.md b/docs/06-cli-reference.md index b0caefd..607a9c2 100644 --- a/docs/06-cli-reference.md +++ b/docs/06-cli-reference.md @@ -91,19 +91,19 @@ backupctl run --all **Dry run — failure detected:** ``` -$ backupctl run vinsware --dry-run +$ backupctl run vinelab --dry-run -=== Dry Run: vinsware === +=== Dry Run: vinelab === Validating config and connectivity without executing backup. - ✅ Config loaded — Project "vinsware" configuration is valid + ✅ Config loaded — Project "vinelab" configuration is valid ✅ Database dumper — Adapter found for database type: postgres ✅ Notifier — Adapter found for notification type: slack - ❌ Restic repo — Cannot access repository at /backups/vinsware: repository does not exist + ❌ Restic repo — Cannot access repository at /backups/vinelab: repository does not exist ✅ Disk space — 42.0 GB free (minimum: 5 GB) - ✅ GPG key — Key found for recipient: vinsware-backup@company.com - ⚠️ Asset paths — 1 of 2 path(s) missing: /data/vinsware/assets + ✅ GPG key — Key found for recipient: vinelab-backup@company.com + ⚠️ Asset paths — 1 of 2 path(s) missing: /data/vinelab/assets -❌ 2 check(s) failed — vinsware is NOT ready for backup. +❌ 2 check(s) failed — vinelab is NOT ready for backup. ``` **Single project backup:** @@ -117,8 +117,8 @@ $ backupctl run --all [2026-03-18 01:00:00] Running backups for 3 enabled project(s)... -[2026-03-18 01:00:00] [1/3] vinsware — starting... -[2026-03-18 01:01:19] [1/3] vinsware — ✅ completed in 1m 19s +[2026-03-18 01:00:00] [1/3] vinelab — starting... +[2026-03-18 01:01:19] [1/3] vinelab — ✅ completed in 1m 19s [2026-03-18 01:01:20] [2/3] project-x — starting... [2026-03-18 01:02:45] [2/3] project-x — ✅ completed in 1m 25s @@ -127,7 +127,7 @@ $ backupctl run --all [2026-03-18 01:02:52] [3/3] project-y — ❌ failed at stage Dump: connection refused === Summary === - ✅ vinsware — success (1m 19s) + ✅ vinelab — success (1m 19s) ✅ project-x — success (1m 25s) ❌ project-y — failed (Dump: connection refused) @@ -137,11 +137,11 @@ $ backupctl run --all **Lock collision:** ``` -$ backupctl run vinsware +$ backupctl run vinelab -❌ Backup already in progress for vinsware. +❌ Backup already in progress for vinelab. Lock held since 2026-03-18 00:00:05 (PID: 1234) - Use "backupctl status vinsware" to check progress. + Use "backupctl status vinelab" to check progress. Exit code: 2 ``` @@ -187,9 +187,9 @@ backupctl status [--last ] **In-progress backup:** ``` -$ backupctl status vinsware +$ backupctl status vinelab -=== vinsware — Current Status === +=== vinelab — Current Status === 🔄 Backup in progress (run: a1b2c3d4) Started: 2026-03-18 00:00:05 (35s ago) @@ -232,20 +232,20 @@ None. ``` $ backupctl health -=== System Health Check === +System unhealthy - ✅ Audit DB — Connected (PostgreSQL 16.2, 142 records) - ❌ Disk space — 3.2 GB free (minimum: 5 GB) - ✅ SSH — Connection to u123456.your-storagebox.de successful - ✅ Restic repo (vinsware) — Repository OK, 42 snapshots - ❌ Restic repo (project-x) — Lock detected, may need unlock - ✅ Restic repo (project-y) — Repository OK, 14 snapshots - -⚠️ 2 check(s) failed. Run "backupctl restic project-x unlock" for stale locks. + ✓ Audit DB + ✗ Disk space (3.2 GB free) + ✓ Storage: vinelab (sftp) + ✗ Storage: project-x (s3) (Fatal: unable to open config file: Stat: The request signature we calculated does not match the signature you provided) + ✓ Storage: project-y (rclone) + Uptime: 2h 15m Exit code: 1 ``` +Each project is probed through its own backend, so one unreachable repository does not mask the others. + --- ## restore @@ -284,14 +284,14 @@ backupctl restore [--only db|assets] [--de **Basic restore:** ``` -$ backupctl restore vinsware abc12345 /tmp/restore +$ backupctl restore vinelab abc12345 /tmp/restore -Restoring snapshot abc12345 for vinsware... - Source: sftp:u123456@u123456.your-storagebox.de:/backups/vinsware +Restoring snapshot abc12345 for vinelab... + Source: sftp:u123456@u123456.your-storagebox.de:/backups/vinelab Target: /tmp/restore Restoring files... - restored /tmp/restore/vinsware_db_20260318_000032.sql.gz + restored /tmp/restore/vinelab_db_20260318_000032.sql.gz restored /tmp/restore/uploads/ (1,248 files) restored /tmp/restore/assets/ (346 files) @@ -301,19 +301,19 @@ Restoring files... **Latest snapshot with decompress and guide:** ``` -$ backupctl restore vinsware latest /tmp/restore --decompress --guide +$ backupctl restore vinelab latest /tmp/restore --decompress --guide -Restoring latest snapshot (abc12345) for vinsware... - Source: sftp:u123456@u123456.your-storagebox.de:/backups/vinsware +Restoring latest snapshot (abc12345) for vinelab... + Source: sftp:u123456@u123456.your-storagebox.de:/backups/vinelab Target: /tmp/restore Restoring files... - restored /tmp/restore/vinsware_db_20260318_000032.sql.gz + restored /tmp/restore/vinelab_db_20260318_000032.sql.gz restored /tmp/restore/uploads/ (1,248 files) restored /tmp/restore/assets/ (346 files) Decompressing dump... - vinsware_db_20260318_000032.sql.gz → vinsware_db_20260318_000032.sql (487 MB) + vinelab_db_20260318_000032.sql.gz → vinelab_db_20260318_000032.sql (487 MB) ✅ Restore complete. @@ -322,13 +322,13 @@ Decompressing dump... The dump file is a pg_dump custom-format archive. To import: 1. Create the target database (if it doesn't exist): - createdb -h -U vinsware_db + createdb -h -U vinelab_db 2. Restore the dump: - pg_restore -h -U -d vinsware_db /tmp/restore/vinsware_db_20260318_000032.sql + pg_restore -h -U -d vinelab_db /tmp/restore/vinelab_db_20260318_000032.sql 3. If restoring to an existing database, add --clean to drop objects first: - pg_restore -h -U -d vinsware_db --clean /tmp/restore/vinsware_db_20260318_000032.sql + pg_restore -h -U -d vinelab_db --clean /tmp/restore/vinelab_db_20260318_000032.sql Note: The dump was originally encrypted with GPG. It was decrypted automatically during restore. The .sql file is ready for import. @@ -337,14 +337,14 @@ automatically during restore. The .sql file is ready for import. **Selective restore — database only:** ``` -$ backupctl restore vinsware abc12345 /tmp/restore --only db +$ backupctl restore vinelab abc12345 /tmp/restore --only db -Restoring snapshot abc12345 for vinsware (database only)... - Source: sftp:u123456@u123456.your-storagebox.de:/backups/vinsware +Restoring snapshot abc12345 for vinelab (database only)... + Source: sftp:u123456@u123456.your-storagebox.de:/backups/vinelab Target: /tmp/restore Restoring files... - restored /tmp/restore/vinsware_db_20260318_000032.sql.gz + restored /tmp/restore/vinelab_db_20260318_000032.sql.gz ✅ Restore complete. Database dump restored to /tmp/restore ``` @@ -352,10 +352,10 @@ Restoring files... **Selective restore — assets only:** ``` -$ backupctl restore vinsware abc12345 /tmp/restore --only assets +$ backupctl restore vinelab abc12345 /tmp/restore --only assets -Restoring snapshot abc12345 for vinsware (assets only)... - Source: sftp:u123456@u123456.your-storagebox.de:/backups/vinsware +Restoring snapshot abc12345 for vinelab (assets only)... + Source: sftp:u123456@u123456.your-storagebox.de:/backups/vinelab Target: /tmp/restore Restoring files... @@ -451,13 +451,13 @@ backupctl prune --all **Single project:** ``` -$ backupctl prune vinsware +$ backupctl prune vinelab -Pruning vinsware with retention: keep_daily=7, keep_weekly=4, keep_monthly=0 +Pruning vinelab with retention: keep_daily=7, keep_weekly=4, keep_monthly=0 Removed 3 snapshots Freed 412.5 MB -✅ Prune complete for vinsware. Repository: 1.4 GB (was 1.8 GB) +✅ Prune complete for vinelab. Repository: 1.4 GB (was 1.8 GB) ``` **All projects:** @@ -465,7 +465,7 @@ Pruning vinsware with retention: keep_daily=7, keep_weekly=4, keep_monthly=0 ``` $ backupctl prune --all -[1/3] vinsware — pruning... +[1/3] vinelab — pruning... Removed 3 snapshots, freed 412.5 MB ✅ [2/3] project-x — pruning... Removed 5 snapshots, freed 287.3 MB ✅ @@ -515,9 +515,9 @@ backupctl logs [--last ] [--failed] **Failed runs only:** ``` -$ backupctl logs vinsware --last 10 --failed +$ backupctl logs vinelab --last 10 --failed -=== vinsware — Failed Runs === +=== vinelab — Failed Runs === RUN ID STARTED DURATION FAILED STAGE ERROR e7f8a9b0 2026-03-14 00:00:03 45s Dump connection timeout @@ -572,7 +572,7 @@ $ backupctl config validate Validating config/projects.yml... - ✅ vinsware — valid + ✅ vinelab — valid ❌ project-x — 2 error(s): • database.password: unresolved variable ${PROJECTX_DB_PASSWORD} • retention.keep_daily: must be a non-negative integer @@ -601,11 +601,11 @@ Reloading configuration... **Import GPG key:** ``` -$ backupctl config import-gpg-key /app/gpg-keys/vinsware-backup.pub +$ backupctl config import-gpg-key /app/gpg-keys/vinelab-backup.pub -Importing GPG key from /app/gpg-keys/vinsware-backup.pub... +Importing GPG key from /app/gpg-keys/vinelab-backup.pub... Key ID: 0xABCDEF1234567890 - User ID: vinsware-backup@company.com + User ID: vinelab-backup@company.com Fingerprint: 1234 5678 ABCD EF01 2345 6789 ABCD EF12 3456 7890 ✅ GPG key imported successfully. @@ -649,12 +649,12 @@ backupctl cache --clear-all **Clear single project cache:** ``` -$ backupctl cache vinsware --clear +$ backupctl cache vinelab --clear -Clearing restic cache for vinsware... +Clearing restic cache for vinelab... Removed 28.5 MB from /root/.cache/restic/abc123def456 -✅ Cache cleared for vinsware. +✅ Cache cleared for vinelab. ``` **Clear all caches:** @@ -663,7 +663,7 @@ Clearing restic cache for vinsware... $ backupctl cache --clear-all Clearing restic cache for all projects... - vinsware — 28.5 MB cleared + vinelab — 28.5 MB cleared project-x — 15.2 MB cleared project-y — 12.1 MB cleared @@ -724,7 +724,7 @@ backupctl restic [args...] **Unlock stale locks:** ``` -$ backupctl restic vinsware unlock +$ backupctl restic vinelab unlock repository abc12345 opened (version 2, compression auto) successfully removed 1 locks @@ -733,9 +733,9 @@ successfully removed 1 locks **Initialize a new repository:** ``` -$ backupctl restic vinsware init +$ backupctl restic vinelab init -created restic repository abc12345 at sftp:u123456@u123456.your-storagebox.de:/backups/vinsware +created restic repository abc12345 at sftp:u123456@u123456.your-storagebox.de:/backups/vinelab Please note that knowledge of your password is required to access the repository. Losing your password means that your data is @@ -745,7 +745,7 @@ irrecoverably lost. **Mount repository for browsing (interactive):** ``` -$ backupctl restic vinsware mount /mnt/restic +$ backupctl restic vinelab mount /mnt/restic repository abc12345 opened (version 2, compression auto) Now serving the repository at /mnt/restic @@ -913,7 +913,7 @@ All commands follow a consistent exit code scheme: Use exit codes for scripting: ```bash -backupctl run vinsware +backupctl run vinelab case $? in 0) echo "Backup succeeded" ;; 1) echo "Backup failed" ;; diff --git a/docs/07-bash-scripts.md b/docs/07-bash-scripts.md index 44b2ee5..225f0eb 100644 --- a/docs/07-bash-scripts.md +++ b/docs/07-bash-scripts.md @@ -103,15 +103,15 @@ Checking prerequisites... Add a project? [Y/n]: Y - Project name: vinsware + Project name: vinelab Database type (postgres/mysql/mongo): postgres - Database host: postgres-vinsware + Database host: postgres-vinelab Database port [5432]: 5432 - Database name: vinsware_db + Database name: vinelab_db Database user: backup_user Database password: ******** Cron schedule [0 0 * * *]: 0 0 * * * - Restic repository path [/backups/vinsware]: /backups/vinsware + Restic repository path [/backups/vinelab]: /backups/vinelab Add another project? [y/N]: N @@ -165,9 +165,9 @@ After installation: ```bash backupctl health # production -backupctl run vinsware --dry-run +backupctl run vinelab --dry-run backupctl-dev health # development -backupctl-dev config show vinsware +backupctl-dev config show vinelab ``` The wrapper scripts check if the target container is running and give a helpful error with start instructions if not. @@ -319,7 +319,7 @@ Tailing backupctl logs (Ctrl-C to stop)... backupctl | [2026-03-18 00:00:00] [info] Application started on port 3100 backupctl | [2026-03-18 00:00:01] [info] Startup recovery completed -backupctl | [2026-03-18 00:00:01] [info] Registered cron: vinsware (0 0 * * *) +backupctl | [2026-03-18 00:00:01] [info] Registered cron: vinelab (0 0 * * *) backupctl | [2026-03-18 00:00:01] [info] Registered cron: project-x (30 1 * * *) backupctl | [2026-03-18 00:00:01] [info] Registered cron: project-y (0 2 * * *) ^C @@ -340,8 +340,8 @@ Opening shell in backupctl container... ✅ Disk space — 42.0 GB free ... -/app # ls /data/backups/vinsware/ -vinsware_db_20260318_000032.sql.gz.gpg +/app # ls /data/backups/vinelab/ +vinelab_db_20260318_000032.sql.gz.gpg uploads/ assets/ @@ -357,7 +357,7 @@ $ ./scripts/backupctl-manage.sh backup-dir === Backup Directory Sizes === -/data/backups/vinsware 1.2 GB +/data/backups/vinelab 1.2 GB /data/backups/project-x 845 MB /data/backups/project-y 320 MB /data/backups/.logs 12 MB @@ -380,12 +380,12 @@ Audit DB: connected (142 records) Projects: 3 configured, 3 enabled Last backups: - vinsware ✅ 2026-03-18 00:01:24 (1m 19s) + vinelab ✅ 2026-03-18 00:01:24 (1m 19s) project-x ✅ 2026-03-18 01:32:25 (1m 25s) project-y ❌ 2026-03-18 02:00:12 (failed: Dump) Next scheduled: - vinsware 2026-03-19 00:00:00 (in 23h 58m) + vinelab 2026-03-19 00:00:00 (in 23h 58m) project-x 2026-03-19 01:30:00 (in 25h 28m) project-y 2026-03-19 02:00:00 (in 25h 58m) ``` diff --git a/docs/08-backup-flow.md b/docs/08-backup-flow.md index 3bd67ee..dc8c6ca 100644 --- a/docs/08-backup-flow.md +++ b/docs/08-backup-flow.md @@ -102,7 +102,7 @@ The orchestrator calls `retryPolicy.evaluateRetry(error, attemptCount)` to decid Each project gets its own lock file at `{BACKUP_BASE_DIR}/{project}/.lock`. The lock file contains the PID and start timestamp of the process holding the lock. ``` -# Example: /data/backups/vinsware/.lock +# Example: /data/backups/vinelab/.lock pid=1234 started=2026-03-18T00:00:05.000Z ``` @@ -145,10 +145,10 @@ A single restic snapshot contains both the database dump and all asset directori backupctl:combined, project:{name} ``` -Example for `vinsware`: +Example for `vinelab`: ``` -backupctl:combined, project:vinsware +backupctl:combined, project:vinelab ``` ### Separate Mode @@ -198,7 +198,7 @@ The timeout check runs between each step. If the elapsed time exceeds `timeout_m If a configured asset path does not exist on disk when the backup runs, the orchestrator: -1. Logs a warning: `Asset path not found: /data/vinsware/missing-dir` +1. Logs a warning: `Asset path not found: /data/vinelab/missing-dir` 2. Fires `NotifierPort.notifyWarning(projectName, message)` with details 3. Continues the backup with the remaining asset paths @@ -302,7 +302,7 @@ Sent at the beginning of each backup run (step 1). **Slack/Email text:** ``` -🔄 Backup started for vinsware +🔄 Backup started for vinelab Run ID: a1b2c3d4 Time: 2026-03-18 00:00:05 (Europe/Berlin) ``` @@ -314,7 +314,7 @@ Sent when a backup completes successfully (step 11). **Slack/Email text:** ``` -✅ Backup completed for vinsware +✅ Backup completed for vinelab Run ID: a1b2c3d4 Duration: 1m 19s Dump size: 145.2 MB @@ -329,7 +329,7 @@ Sent when a backup fails at any stage (step 11). **Slack/Email text:** ``` -❌ Backup failed for vinsware +❌ Backup failed for vinelab Run ID: a1b2c3d4 Failed at stage: Sync (attempt 3/3) Duration: 2m 10s @@ -343,7 +343,7 @@ Sent when a backup exceeds `timeout_minutes` (mid-run, between steps). **Slack/Email text:** ``` -⚠️ Backup timeout warning for vinsware +⚠️ Backup timeout warning for vinelab Run ID: a1b2c3d4 Elapsed: 32m (timeout: 30m) Current stage: Sync (6/11) @@ -359,7 +359,7 @@ Sent on the schedule defined by `DAILY_SUMMARY_CRON` (default: `0 7 * * *`). ``` 📊 Daily Backup Summary — 2026-03-18 - ✅ vinsware — success (1m 19s) + ✅ vinelab — success (1m 19s) ✅ project-x — success (1m 25s) ❌ project-y — failed (Dump: connection refused) @@ -373,11 +373,11 @@ All notification types sent via the webhook adapter use this JSON structure: ```json { "event": "backup_success", - "project": "vinsware", - "text": "✅ Backup completed for vinsware\nRun ID: a1b2c3d4\nDuration: 1m 19s\nDump size: 145.2 MB\nSnapshot: abc12345\nRepository size: 1.8 GB", + "project": "vinelab", + "text": "✅ Backup completed for vinelab\nRun ID: a1b2c3d4\nDuration: 1m 19s\nDump size: 145.2 MB\nSnapshot: abc12345\nRepository size: 1.8 GB", "data": { "runId": "a1b2c3d4", - "project": "vinsware", + "project": "vinelab", "status": "success", "duration": 79, "dumpSize": 152253030, diff --git a/docs/12-troubleshooting.md b/docs/12-troubleshooting.md index ff31729..2ee4267 100644 --- a/docs/12-troubleshooting.md +++ b/docs/12-troubleshooting.md @@ -564,9 +564,10 @@ Couldn't create directory: Failure Use **relative paths** in `projects.yml`: ```yaml -restic: - repository_path: backups/myproject # ✅ Relative — correct - # repository_path: /backups/myproject # ❌ Absolute — will fail +storage: + type: sftp + repository: backups/myproject # ✅ Relative — correct + # repository: /backups/myproject # ❌ Absolute — will fail ``` Create the directory via SFTP using relative paths: diff --git a/docs/15-faq.md b/docs/15-faq.md index 572b64a..19e98ae 100644 --- a/docs/15-faq.md +++ b/docs/15-faq.md @@ -153,8 +153,9 @@ backupctl restic myproject init ```yaml # Relative to the storage box user's home (avoid a leading slash, e.g. not `/backups/...`) -restic: - repository_path: backups/myproject +storage: + type: sftp + repository: backups/myproject ``` The resulting SFTP URI should look like `sftp:user@host:backups/myproject` (no leading `/` after the colon). @@ -988,7 +989,7 @@ Set `enabled: false` in `config/projects.yml`: ```yaml projects: - - name: vinsware + - name: vinelab enabled: false # Pauses scheduled and --all backups cron: "0 0 * * *" # ... rest of config @@ -1009,9 +1010,9 @@ backupctl config reload **What still works:** -- `backupctl run vinsware` — manual single-project runs still work, so you can do ad-hoc backups while paused -- `backupctl snapshots vinsware` — you can still browse existing snapshots -- `backupctl restore vinsware ...` — restores from existing snapshots still work +- `backupctl run vinelab` — manual single-project runs still work, so you can do ad-hoc backups while paused +- `backupctl snapshots vinelab` — you can still browse existing snapshots +- `backupctl restore vinelab ...` — restores from existing snapshots still work To re-enable, set `enabled: true` and reload config again. diff --git a/docs/16-monitoring.md b/docs/16-monitoring.md index d2bff86..83bbc3b 100644 --- a/docs/16-monitoring.md +++ b/docs/16-monitoring.md @@ -53,7 +53,7 @@ Add a `monitor` block to any project in `config/projects.yml`: ```yaml projects: - - name: vinsware + - name: vinelab cron: '0 0 * * *' # ... existing config ... monitor: @@ -89,7 +89,7 @@ For each project you want to monitor: 1. Open your Uptime Kuma dashboard 2. Click **Add New Monitor** 3. Set **Monitor Type** to **Push** -4. Set **Friendly Name** (e.g., `vinsware-backup`) +4. Set **Friendly Name** (e.g., `vinelab-backup`) 5. Set **Heartbeat Interval** to match your backup schedule (see [Recommended Intervals](#recommended-intervals)) 6. Set **Retries** to `0` (backupctl sends `status=down` on failure — no need for Kuma to retry) 7. Click **Save** @@ -180,15 +180,59 @@ System healthy ✓ Audit DB ✓ Disk space (42 GB free) - ✓ SSH connection - ✓ SSH auth - ✓ Restic repos + ✓ Storage: vinelab (sftp) + ✓ Storage: project-x (s3) ✓ Uptime Kuma Uptime: 2h 15m ``` The HTTP health endpoint also includes Kuma status when configured. +### HTTP Endpoints + +There are two, and the distinction matters if you monitor them. + +| Endpoint | Checks | Use for | +|----------|--------|---------| +| `GET /health/live` | Audit DB, disk. Container-local only | Docker `HEALTHCHECK`, orchestrator liveness probes | +| `GET /health` | Everything above **plus** a real reachability probe of every enabled project's repository | Dashboards, alerting, on-call debugging | + +Both return `200` when healthy and `503` when not. + +The container's `HEALTHCHECK` points at `/health/live` deliberately. `/health` reaches out to your storage provider, and restarting the container cannot fix a Backblaze or Drive outage — pointing Docker at it would turn a remote outage into a restart loop. + +```bash +curl -s localhost:3100/health | jq +``` + +```json +{ + "status": "unhealthy", + "checks": { + "auditDb": true, + "diskSpace": { "available": true, "freeGb": 42.1 }, + "storage": [ + { "project": "vinelab", "backendType": "sftp", "reachable": true }, + { + "project": "project-x", + "backendType": "s3", + "reachable": false, + "error": "Fatal: unable to open config file: Stat: The request signature we calculated does not match the signature you provided" + } + ] + }, + "uptime": 8130.4 +} +``` + +Each entry is a real `restic cat config` against that project's repository, which proves reachability, credentials and the repository password in one call. Failures are isolated per project. + +Results are cached for `HEALTH_STORAGE_CHECK_TTL_SECONDS` (default 300). Alerting on `/health` more often than that returns the cached answer rather than issuing new requests — which matters because each probe is a billable transaction on B2 and a rate-limited call on Drive. + +::: tip Breaking change in this version +`/health` previously reported `checks.ssh` and `checks.resticRepos`. Both are replaced by the `checks.storage` array. If you alert on those fields, update your queries. `backupctl health` output changed the same way. +::: + --- ## Testing diff --git a/docs/initial/prd.md b/docs/initial/prd.md index e0d7434..9055777 100644 --- a/docs/initial/prd.md +++ b/docs/initial/prd.md @@ -259,8 +259,8 @@ LOG_MAX_FILES=5 GPG_KEYS_DIR=/app/gpg-keys # Project DB passwords (referenced in projects.yml via ${}) -VINSWARE_DB_PASSWORD=secret -VINSWARE_RESTIC_PASSWORD=restic-secret +VINELAB_DB_PASSWORD=secret +VINELAB_RESTIC_PASSWORD=restic-secret PROJECTX_DB_PASSWORD=secret PROJECTY_DB_PASSWORD=secret EMAIL_PASSWORD=smtp-secret @@ -270,30 +270,30 @@ EMAIL_PASSWORD=smtp-secret ```yaml projects: - - name: vinsware + - name: vinelab enabled: true cron: "0 0 * * *" timeout_minutes: 30 database: type: postgres - host: postgres-vinsware + host: postgres-vinelab port: 5432 - name: vinsware_db + name: vinelab_db user: backup_user - password: ${VINSWARE_DB_PASSWORD} + password: ${VINELAB_DB_PASSWORD} compression: enabled: true # per-project override (default: true) assets: paths: - - /data/vinsware/uploads - - /data/vinsware/assets + - /data/vinelab/uploads + - /data/vinelab/assets restic: - repository_path: /backups/vinsware - password: ${VINSWARE_RESTIC_PASSWORD} + repository_path: /backups/vinelab + password: ${VINELAB_RESTIC_PASSWORD} snapshot_mode: combined retention: @@ -305,11 +305,11 @@ projects: encryption: enabled: true type: gpg - recipient: vinsware-backup@company.com + recipient: vinelab-backup@company.com hooks: - pre_backup: "curl -s http://vinsware-app:3000/maintenance/on" - post_backup: "curl -s http://vinsware-app:3000/maintenance/off" + pre_backup: "curl -s http://vinelab-app:3000/maintenance/on" + post_backup: "curl -s http://vinelab-app:3000/maintenance/off" verification: enabled: true @@ -317,7 +317,7 @@ projects: notification: type: slack config: - webhook_url: https://hooks.slack.com/services/VINSWARE/SPECIFIC/HOOK + webhook_url: https://hooks.slack.com/services/VINELAB/SPECIFIC/HOOK - name: project-x enabled: true @@ -400,9 +400,9 @@ projects: ``` ${BACKUP_BASE_DIR}/ # default: /data/backups -├── vinsware/ -│ ├── vinsware_backup_20260318_000000_a1b2.sql.gz -│ ├── vinsware_backup_20260317_000000_c3d4.sql.gz +├── vinelab/ +│ ├── vinelab_backup_20260318_000000_a1b2.sql.gz +│ ├── vinelab_backup_20260317_000000_c3d4.sql.gz │ └── .lock # file-based lock (present while backup running) ├── project-x/ │ └── ... @@ -443,7 +443,7 @@ export interface DatabaseDumperPort { ```typescript export interface SyncOptions { - tags: string[]; // e.g. ['backupctl:db', 'project:vinsware'] + tags: string[]; // e.g. ['backupctl:db', 'project:vinelab'] snapshotMode: 'combined' | 'separate'; } @@ -646,7 +646,7 @@ Per-project backup execution (triggered by cron or CLI): A per-project lock prevents concurrent backups for the same project: - **Cron overlap:** If a cron-triggered backup is still running when the next cron fires for the same project, the new run is **queued** and executes after the current one completes. -- **CLI collision:** If a user runs `backupctl run vinsware` while a backup is already in progress, the CLI **rejects with an error**: "Backup already in progress for vinsware". +- **CLI collision:** If a user runs `backupctl run vinelab` while a backup is already in progress, the CLI **rejects with an error**: "Backup already in progress for vinelab". - **`run --all`:** Runs projects **sequentially** in YAML order. If one project fails, the remaining projects still execute. ### 7.2 Failure Recovery @@ -704,7 +704,7 @@ After `backupctl restore` extracts files, two additional flags help with the nex - MySQL: `mysql -u ... -p ... < ` - MongoDB: `mongorestore --gzip --archive= --db=...` -Both flags can be combined: `backupctl restore vinsware latest /data/restore/ --decompress --guide` +Both flags can be combined: `backupctl restore vinelab latest /data/restore/ --decompress --guide` ### 7.6 Restic Cache Management @@ -770,37 +770,37 @@ The `restic` subcommand resolves the project's `RESTIC_REPOSITORY` and `RESTIC_P ```bash # List snapshots (raw restic output) -backupctl restic vinsware snapshots +backupctl restic vinelab snapshots # Check repo integrity -backupctl restic vinsware check +backupctl restic vinelab check # Show repo stats -backupctl restic vinsware stats +backupctl restic vinelab stats # Diff two snapshots -backupctl restic vinsware diff abc123 def456 +backupctl restic vinelab diff abc123 def456 # Mount repo for browsing (requires FUSE) -backupctl restic vinsware mount /mnt/restore +backupctl restic vinelab mount /mnt/restore # Show files in a snapshot -backupctl restic vinsware ls latest +backupctl restic vinelab ls latest # Find a file across snapshots -backupctl restic vinsware find "*.sql.gz" +backupctl restic vinelab find "*.sql.gz" # Unlock a stuck repo -backupctl restic vinsware unlock +backupctl restic vinelab unlock # Show raw key info -backupctl restic vinsware key list +backupctl restic vinelab key list # Cat a file from a snapshot -backupctl restic vinsware dump latest /data/backups/vinsware/backup_2026-03-18.sql.gz +backupctl restic vinelab dump latest /data/backups/vinelab/backup_2026-03-18.sql.gz # Initialize repo (one-time setup) -backupctl restic vinsware init +backupctl restic vinelab init ``` **How it works internally:** @@ -829,62 +829,62 @@ async run(project: string, resticArgs: string[]): Promise { ```bash # --- Backup operations --- -backupctl run vinsware # Run backup now +backupctl run vinelab # Run backup now backupctl run --all # Backup all projects (sequential) # --- Status & monitoring --- backupctl health # Full health check backupctl status # All projects summary -backupctl status vinsware # Detailed vinsware history -backupctl status vinsware --last 5 # Last 5 entries +backupctl status vinelab # Detailed vinelab history +backupctl status vinelab --last 5 # Last 5 entries # --- Restore --- -backupctl restore vinsware a1b2c3d4 /data/restore/vinsware -backupctl restore vinsware latest /data/restore/vinsware -backupctl restore vinsware latest /data/restore/vinsware --only db # DB dump only -backupctl restore vinsware latest /data/restore/vinsware --only assets # Assets only +backupctl restore vinelab a1b2c3d4 /data/restore/vinelab +backupctl restore vinelab latest /data/restore/vinelab +backupctl restore vinelab latest /data/restore/vinelab --only db # DB dump only +backupctl restore vinelab latest /data/restore/vinelab --only assets # Assets only # --- Snapshots --- -backupctl snapshots vinsware # All snapshots (with tags) -backupctl snapshots vinsware --last 10 # Last 10 +backupctl snapshots vinelab # All snapshots (with tags) +backupctl snapshots vinelab --last 10 # Last 10 # --- Prune --- -backupctl prune vinsware # Prune vinsware +backupctl prune vinelab # Prune vinelab backupctl prune --all # Prune all # --- Logs --- -backupctl logs vinsware # All logs -backupctl logs vinsware --last 20 # Last 20 -backupctl logs vinsware --failed # Failed only +backupctl logs vinelab # All logs +backupctl logs vinelab --last 20 # Last 20 +backupctl logs vinelab --failed # Failed only # --- Config --- backupctl config validate # Validate everything -backupctl config show vinsware # Show resolved config +backupctl config show vinelab # Show resolved config backupctl config reload # Reload YAML + re-register crons -backupctl config import-gpg-key ./keys/vinsware.pub.gpg # Import GPG key +backupctl config import-gpg-key ./keys/vinelab.pub.gpg # Import GPG key # --- Dry run --- -backupctl run vinsware --dry-run # Validate without executing +backupctl run vinelab --dry-run # Validate without executing # --- Restore with guidance --- -backupctl restore vinsware latest /data/restore/vinsware --decompress # Extract + decompress -backupctl restore vinsware latest /data/restore/vinsware --guide # Print import instructions -backupctl restore vinsware latest /data/restore/vinsware --decompress --guide # Both +backupctl restore vinelab latest /data/restore/vinelab --decompress # Extract + decompress +backupctl restore vinelab latest /data/restore/vinelab --guide # Print import instructions +backupctl restore vinelab latest /data/restore/vinelab --decompress --guide # Both # --- Cache management --- -backupctl cache vinsware # Show cache size -backupctl cache vinsware --clear # Clear project cache +backupctl cache vinelab # Show cache size +backupctl cache vinelab --clear # Clear project cache backupctl cache --clear-all # Clear all caches # --- Restic passthrough --- -backupctl restic vinsware snapshots # Raw restic snapshots -backupctl restic vinsware check # Repo integrity -backupctl restic vinsware stats # Repo stats -backupctl restic vinsware diff abc123 def456 # Diff snapshots -backupctl restic vinsware ls latest # List files in latest -backupctl restic vinsware find "*.sql.gz" # Find files -backupctl restic vinsware unlock # Unlock stuck repo -backupctl restic vinsware init # Init new repo +backupctl restic vinelab snapshots # Raw restic snapshots +backupctl restic vinelab check # Repo integrity +backupctl restic vinelab stats # Repo stats +backupctl restic vinelab diff abc123 def456 # Diff snapshots +backupctl restic vinelab ls latest # List files in latest +backupctl restic vinelab find "*.sql.gz" # Find files +backupctl restic vinelab unlock # Unlock stuck repo +backupctl restic vinelab init # Init new repo ``` ### 8.4 Deploy Script (`scripts/deploy.sh`) — Host Only @@ -1046,8 +1046,8 @@ services: - ./ssh-keys:/root/.ssh:ro - ./gpg-keys:/app/gpg-keys:ro # Mount asset directories (add all paths referenced in projects.yml) - - /data/vinsware/uploads:/data/vinsware/uploads:ro - - /data/vinsware/assets:/data/vinsware/assets:ro + - /data/vinelab/uploads:/data/vinelab/uploads:ro + - /data/vinelab/assets:/data/vinelab/assets:ro - /data/projectx/storage:/data/projectx/storage:ro networks: - backupctl-network @@ -1101,7 +1101,7 @@ scripts/deploy.sh # and prompts for confirmation (interactive only, fails in cron) # 5. Initialize restic repo per project -backupctl restic vinsware init +backupctl restic vinelab init backupctl restic project-x init backupctl restic project-y init ``` @@ -1112,7 +1112,7 @@ backupctl restic project-y init ``` /backups/ -├── vinsware/ +├── vinelab/ │ ├── config │ ├── data/ │ ├── index/ @@ -1181,15 +1181,15 @@ npx typeorm migration:generate -d src/adapters/audit/data-source.ts src/adapters ### 13.1 Backup Started ``` -🔄 Backup started — vinsware +🔄 Backup started — vinelab Time: 2026-03-18 00:00:00 IST ``` ### 13.2 Backup Success ``` -✅ Backup completed — vinsware -DB: vinsware_db | Dump: 245 MB | Encrypted: Yes | Verified: Yes +✅ Backup completed — vinelab +DB: vinelab_db | Dump: 245 MB | Encrypted: Yes | Verified: Yes Snapshot: a1b2c3d4 | Mode: combined New files: 12 | Changed: 3 | Added: 52 MB Pruned: 2 snapshots | Local cleaned: 1 file @@ -1199,17 +1199,17 @@ Duration: 3m 12s ### 13.3 Backup Failed ``` -❌ Backup failed — vinsware +❌ Backup failed — vinelab Stage: restic sync | Retry: 3/3 Error: connection timeout to storage box -Dump file: /data/backups/vinsware/backup_2026-03-18_000000.sql.gz +Dump file: /data/backups/vinelab/backup_2026-03-18_000000.sql.gz Duration: 5m 42s ``` ### 13.4 Backup Timeout Warning ``` -⚠️ Backup timeout warning — vinsware +⚠️ Backup timeout warning — vinelab Elapsed: 35m | Timeout threshold: 30m Current stage: restic sync Backup is still running — this is a warning, not a failure. @@ -1220,7 +1220,7 @@ Backup is still running — this is a warning, not a failure. ``` 📊 Daily Backup Summary — 2026-03-18 -✅ vinsware — 245 MB — 3m 12s — a1b2c3d4 +✅ vinelab — 245 MB — 3m 12s — a1b2c3d4 ✅ project-x — 128 MB — 1m 45s — e5f6g7h8 ❌ project-y — FAILED — restic sync timeout @@ -1234,11 +1234,11 @@ The webhook notifier POSTs `application/json` with a `text` field containing the ```json { "event": "backup_success", - "project": "vinsware", - "text": "✅ Backup completed — vinsware\nDB: vinsware_db | Dump: 245 MB | Encrypted: Yes | Verified: Yes\nSnapshot: a1b2c3d4 | Mode: combined\nNew files: 12 | Changed: 3 | Added: 52 MB\nPruned: 2 snapshots | Local cleaned: 1 file\nDuration: 3m 12s", + "project": "vinelab", + "text": "✅ Backup completed — vinelab\nDB: vinelab_db | Dump: 245 MB | Encrypted: Yes | Verified: Yes\nSnapshot: a1b2c3d4 | Mode: combined\nNew files: 12 | Changed: 3 | Added: 52 MB\nPruned: 2 snapshots | Local cleaned: 1 file\nDuration: 3m 12s", "data": { "run_id": "uuid", - "project_name": "vinsware", + "project_name": "vinelab", "status": "success", "snapshot_id": "a1b2c3d4", "dump_size_bytes": 257949696, diff --git a/jest.config.ts b/jest.config.ts index 6bf5e57..0fefb4d 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -24,6 +24,7 @@ const config: Config = { '^@infrastructure/(.*)$': '/src/infrastructure/$1', '^@common/(.*)$': '/src/common/$1', '^@shared/(.*)$': '/src/shared/$1', + '^@test/(.*)$': '/test/$1', }, coverageThreshold: { global: { diff --git a/scripts/backupctl-manage.sh b/scripts/backupctl-manage.sh index b8234b4..ed02852 100755 --- a/scripts/backupctl-manage.sh +++ b/scripts/backupctl-manage.sh @@ -32,8 +32,8 @@ cmd_setup() { # Create required directories echo "[1/6] Creating directories..." - mkdir -p config ssh-keys gpg-keys - echo " Created: config/ ssh-keys/ gpg-keys/" + mkdir -p config ssh-keys gpg-keys rclone-config + echo " Created: config/ ssh-keys/ gpg-keys/ rclone-config/" # Generate .env if missing echo "[2/6] Checking .env..." @@ -142,8 +142,9 @@ projects: [] # password: ${DB_PASSWORD} # compression: # enabled: true - # restic: - # repository_path: backups/myproject + # storage: + # type: sftp # sftp | s3 | b2 | rclone | local + # repository: backups/myproject # password: ${RESTIC_PASSWORD} # snapshot_mode: combined # retention: diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index e5dcafb..61a73b6 100755 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -12,6 +12,15 @@ if [ ! -w "$BACKUP_BASE_DIR" ]; then exit 1 fi +# rclone rewrites refreshed OAuth tokens into its config. A read-only mount loses them +# on every container recreate, which surfaces much later as an expired-token failure. +RCLONE_CONFIG_DIR="${RCLONE_CONFIG_DIR:-/home/node/.config/rclone}" +if [ -d "$RCLONE_CONFIG_DIR" ] && [ ! -w "$RCLONE_CONFIG_DIR" ]; then + echo "WARNING: $RCLONE_CONFIG_DIR is not writable by user $(id -u):$(id -g)" + echo " rclone backends will lose refreshed OAuth tokens when the container is recreated." + echo " Fix: run 'sudo chown -R $(id -u):$(id -g) rclone-config' on the host" +fi + # Run pending database migrations if [ -f dist/db/datasource.js ]; then echo "Running database migrations..." diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 402c695..95c8fc0 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -258,9 +258,9 @@ main() { echo "" echo -e " ${BOLD}Usage:${RESET}" echo -e " ${CYAN}${PROD_CMD} health${RESET} ${DIM}# production${RESET}" - echo -e " ${CYAN}${PROD_CMD} run vinsware --dry-run${RESET} ${DIM}# production${RESET}" + echo -e " ${CYAN}${PROD_CMD} run vinelab --dry-run${RESET} ${DIM}# production${RESET}" echo -e " ${CYAN}${DEV_CMD} health${RESET} ${DIM}# development${RESET}" - echo -e " ${CYAN}${DEV_CMD} config show vinsware${RESET} ${DIM}# development${RESET}" + echo -e " ${CYAN}${DEV_CMD} config show vinelab${RESET} ${DIM}# development${RESET}" echo "" } diff --git a/scripts/install.sh b/scripts/install.sh index 508f8ce..a5279a7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1113,8 +1113,9 @@ add_project() { fi yaml+=""$'\n' - yaml+=" restic:"$'\n' - yaml+=" repository_path: ${proj_restic_path}"$'\n' + yaml+=" storage:"$'\n' + yaml+=" type: sftp"$'\n' + yaml+=" repository: ${proj_restic_path}"$'\n' if [ "$proj_restic_password_mode" = "custom" ]; then yaml+=" password: \${${proj_env_key}_RESTIC_PASSWORD}"$'\n' else @@ -1558,7 +1559,7 @@ step_docker() { print_info "Creating remote directories on storage box..." for proj_yaml in "${PROJECTS[@]}"; do local repo_path - repo_path=$(echo "$proj_yaml" | grep 'repository_path:' | head -1 | sed 's/.*repository_path: //') + repo_path=$(echo "$proj_yaml" | grep -E '^[[:space:]]*repository:' | head -1 | sed 's/.*repository: //') if [ -n "$repo_path" ]; then echo -ne " Creating remote directory: ${repo_path}..." if echo "mkdir ${repo_path}" | sftp -b - \ diff --git a/src/app/shared-infra.module.ts b/src/app/shared-infra.module.ts index 5ead464..3ee4a56 100644 --- a/src/app/shared-infra.module.ts +++ b/src/app/shared-infra.module.ts @@ -6,6 +6,12 @@ import { EnvValidationService } from '@common/validation/env-validation.service' import { UpgradeCheckService } from '@common/upgrade/upgrade-check.service'; import { FileBackupLockAdapter } from '@domain/backup/infrastructure/adapters/lock/file-backup-lock.adapter'; import { ResticStorageFactory } from '@domain/backup/infrastructure/adapters/storage/restic-storage.factory'; +import { ResticBackendRegistry } from '@domain/backup/infrastructure/adapters/storage/backends/restic-backend.registry'; +import { SftpBackendResolver } from '@domain/backup/infrastructure/adapters/storage/backends/sftp-backend.resolver'; +import { S3BackendResolver } from '@domain/backup/infrastructure/adapters/storage/backends/s3-backend.resolver'; +import { B2BackendResolver } from '@domain/backup/infrastructure/adapters/storage/backends/b2-backend.resolver'; +import { RcloneBackendResolver } from '@domain/backup/infrastructure/adapters/storage/backends/rclone-backend.resolver'; +import { LocalBackendResolver } from '@domain/backup/infrastructure/adapters/storage/backends/local-backend.resolver'; import { GpgKeyManagerAdapter } from '@domain/backup/infrastructure/adapters/encryptors/gpg-key-manager.adapter'; import { @@ -24,6 +30,12 @@ import { { provide: CLOCK_PORT, useClass: SystemClockAdapter }, { provide: FILESYSTEM_PORT, useClass: LocalFilesystemAdapter }, { provide: BACKUP_LOCK_PORT, useClass: FileBackupLockAdapter }, + SftpBackendResolver, + S3BackendResolver, + B2BackendResolver, + RcloneBackendResolver, + LocalBackendResolver, + ResticBackendRegistry, { provide: REMOTE_STORAGE_FACTORY, useClass: ResticStorageFactory }, { provide: GPG_KEY_MANAGER_PORT, useClass: GpgKeyManagerAdapter }, ], diff --git a/src/common/helpers/mask.util.ts b/src/common/helpers/mask.util.ts new file mode 100644 index 0000000..b647e26 --- /dev/null +++ b/src/common/helpers/mask.util.ts @@ -0,0 +1,19 @@ +const SECRET_KEY_PATTERN = /pass|secret|token|key|credential|webhook|dsn|auth/i; + +const MASK = '********'; + +export function maskSecretValues>(source: T): Record { + const masked: Record = {}; + + for (const [key, value] of Object.entries(source)) { + if (SECRET_KEY_PATTERN.test(key)) { + masked[key] = MASK; + } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + masked[key] = maskSecretValues(value as Record); + } else { + masked[key] = value; + } + } + + return masked; +} diff --git a/src/common/validation/env-validation.service.ts b/src/common/validation/env-validation.service.ts index 2ff907a..df8e836 100644 --- a/src/common/validation/env-validation.service.ts +++ b/src/common/validation/env-validation.service.ts @@ -1,6 +1,9 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; +import { CONFIG_LOADER_PORT } from '@common/di/injection-tokens'; + interface EnvRule { readonly key: string; readonly requiredInProduction: boolean; @@ -10,17 +13,23 @@ interface EnvRule { const REQUIRED_ENV_VARS: EnvRule[] = [ { key: 'AUDIT_DB_HOST', requiredInProduction: true, description: 'Audit database host' }, { key: 'AUDIT_DB_PASSWORD', requiredInProduction: true, description: 'Audit database password' }, + { key: 'RESTIC_PASSWORD', requiredInProduction: false, description: 'Global restic password (can be per-project)' }, +]; + +const SFTP_ENV_VARS: EnvRule[] = [ { key: 'HETZNER_SSH_HOST', requiredInProduction: true, description: 'Hetzner Storage Box SSH host' }, { key: 'HETZNER_SSH_USER', requiredInProduction: true, description: 'Hetzner SSH user' }, { key: 'HETZNER_SSH_KEY_PATH', requiredInProduction: true, description: 'Path to SSH private key' }, - { key: 'RESTIC_PASSWORD', requiredInProduction: false, description: 'Global restic password (can be per-project)' }, ]; @Injectable() export class EnvValidationService implements OnModuleInit { private readonly logger = new Logger(EnvValidationService.name); - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + @Inject(CONFIG_LOADER_PORT) private readonly configLoader: ConfigLoaderPort, + ) {} onModuleInit(): void { const isProduction = this.configService.get('NODE_ENV') === 'production'; @@ -28,10 +37,12 @@ export class EnvValidationService implements OnModuleInit { if (isCliMode) return; + const rules = [...REQUIRED_ENV_VARS, ...(this.hasSftpProject() ? SFTP_ENV_VARS : [])]; + const missing: string[] = []; const warnings: string[] = []; - for (const rule of REQUIRED_ENV_VARS) { + for (const rule of rules) { const value = this.configService.get(rule.key); if (!value && rule.requiredInProduction && isProduction) { @@ -54,4 +65,23 @@ export class EnvValidationService implements OnModuleInit { ); } } + + /** + * A broken projects.yml is `config validate`'s problem, not boot's — an unreadable + * config must not turn into a misleading "missing env var" failure. + */ + private hasSftpProject(): boolean { + try { + return this.configLoader + .loadAll() + .some((project) => project.enabled && project.storage.type === 'sftp'); + } catch (error) { + this.logger.warn( + `Could not read project config to determine storage backends; skipping backend-specific env checks: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return false; + } + } } diff --git a/src/domain/audit/domain/health-check-result.model.ts b/src/domain/audit/domain/health-check-result.model.ts index 815c1e5..3420d80 100644 --- a/src/domain/audit/domain/health-check-result.model.ts +++ b/src/domain/audit/domain/health-check-result.model.ts @@ -1,46 +1,44 @@ +export interface StorageHealthCheck { + readonly project: string; + readonly backendType: string; + readonly reachable: boolean; + readonly error: string | null; +} + +export interface HealthCheckResultParams { + readonly auditDbConnected: boolean; + readonly diskSpaceAvailable: boolean; + readonly diskFreeGb: number; + readonly storageChecks: readonly StorageHealthCheck[]; + readonly uptime: number; + readonly uptimeKumaConnected?: boolean; + readonly uptimeKumaConfigured?: boolean; +} + export class HealthCheckResult { readonly auditDbConnected: boolean; readonly diskSpaceAvailable: boolean; readonly diskFreeGb: number; - readonly sshConnected: boolean; - readonly sshAuthenticated: boolean; - readonly resticReposHealthy: boolean; + readonly storageChecks: readonly StorageHealthCheck[]; readonly uptime: number; - readonly sshConfigured: boolean; readonly uptimeKumaConnected: boolean; readonly uptimeKumaConfigured: boolean; - constructor( - auditDbConnected: boolean, - diskSpaceAvailable: boolean, - diskFreeGb: number, - sshConnected: boolean, - sshAuthenticated: boolean, - resticReposHealthy: boolean, - uptime: number, - sshConfigured = true, - uptimeKumaConnected = false, - uptimeKumaConfigured = false, - ) { - this.auditDbConnected = auditDbConnected; - this.diskSpaceAvailable = diskSpaceAvailable; - this.diskFreeGb = diskFreeGb; - this.sshConnected = sshConnected; - this.sshAuthenticated = sshAuthenticated; - this.resticReposHealthy = resticReposHealthy; - this.uptime = uptime; - this.sshConfigured = sshConfigured; - this.uptimeKumaConnected = uptimeKumaConnected; - this.uptimeKumaConfigured = uptimeKumaConfigured; + constructor(params: HealthCheckResultParams) { + this.auditDbConnected = params.auditDbConnected; + this.diskSpaceAvailable = params.diskSpaceAvailable; + this.diskFreeGb = params.diskFreeGb; + this.storageChecks = params.storageChecks; + this.uptime = params.uptime; + this.uptimeKumaConnected = params.uptimeKumaConnected ?? false; + this.uptimeKumaConfigured = params.uptimeKumaConfigured ?? false; } - isHealthy(): boolean { - const coreHealthy = this.auditDbConnected && this.diskSpaceAvailable; - - if (!this.sshConfigured) { - return coreHealthy; - } + storageHealthy(): boolean { + return this.storageChecks.every((check) => check.reachable); + } - return coreHealthy && this.sshConnected && this.sshAuthenticated && this.resticReposHealthy; + isHealthy(): boolean { + return this.auditDbConnected && this.diskSpaceAvailable && this.storageHealthy(); } } diff --git a/src/domain/backup/application/ports/remote-storage.port.ts b/src/domain/backup/application/ports/remote-storage.port.ts index d520ef2..148a673 100644 --- a/src/domain/backup/application/ports/remote-storage.port.ts +++ b/src/domain/backup/application/ports/remote-storage.port.ts @@ -18,4 +18,5 @@ export interface RemoteStoragePort { getCacheInfo(): Promise; clearCache(): Promise; unlock(): Promise; + checkConnectivity(): Promise; } diff --git a/src/domain/backup/application/use-cases/clear-cache/clear-cache.use-case.ts b/src/domain/backup/application/use-cases/clear-cache/clear-cache.use-case.ts index f1b53a0..f2ac102 100644 --- a/src/domain/backup/application/use-cases/clear-cache/clear-cache.use-case.ts +++ b/src/domain/backup/application/use-cases/clear-cache/clear-cache.use-case.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remote-storage-factory.port'; import { REMOTE_STORAGE_FACTORY, CONFIG_LOADER_PORT } from '@common/di/injection-tokens'; @@ -6,6 +6,8 @@ import { ClearCacheCommand } from './clear-cache.command'; @Injectable() export class ClearCacheUseCase { + private readonly logger = new Logger(ClearCacheUseCase.name); + constructor( @Inject(REMOTE_STORAGE_FACTORY) private readonly storageFactory: RemoteStorageFactoryPort, @Inject(CONFIG_LOADER_PORT) private readonly configLoader: ConfigLoaderPort, @@ -15,8 +17,12 @@ export class ClearCacheUseCase { if (command.clearAll) { const projects = this.configLoader.loadAll().filter((project) => project.enabled); for (const project of projects) { - const storage = this.storageFactory.create(project); - await storage.clearCache(); + try { + const storage = this.storageFactory.create(project); + await storage.clearCache(); + } catch (error) { + this.logger.warn(`Failed to clear cache for ${project.name}`, error); + } } return; } diff --git a/src/domain/backup/application/use-cases/run-backup/run-backup.use-case.ts b/src/domain/backup/application/use-cases/run-backup/run-backup.use-case.ts index a2b408d..56d8810 100644 --- a/src/domain/backup/application/use-cases/run-backup/run-backup.use-case.ts +++ b/src/domain/backup/application/use-cases/run-backup/run-backup.use-case.ts @@ -262,7 +262,7 @@ export class RunBackupUseCase { try { const storage = this.storageFactory.create(config); await storage.listSnapshots(); - checks.push({ name: 'Restic repo', passed: true, message: `Repository accessible at ${config.restic.repositoryPath}` }); + checks.push({ name: 'Restic repo', passed: true, message: `Repository accessible at ${config.storage.repository}` }); } catch (error) { checks.push({ name: 'Restic repo', passed: false, message: `Repository unreachable: ${(error as Error).message}` }); } @@ -421,7 +421,7 @@ export class RunBackupUseCase { () => storage.sync(syncPaths, { tags: this.buildTags(config, timestamp), - snapshotMode: config.restic.snapshotMode, + snapshotMode: config.storage.snapshotMode, }), (retries) => { totalRetries += retries; }, ); @@ -481,7 +481,7 @@ export class RunBackupUseCase { encrypted, verified, backupType: this.resolveBackupType(config), - snapshotMode: config.restic.snapshotMode, + snapshotMode: config.storage.snapshotMode, errorStage, errorMessage, retryCount: totalRetries, diff --git a/src/domain/backup/infrastructure/adapters/storage/backends/b2-backend.resolver.ts b/src/domain/backup/infrastructure/adapters/storage/backends/b2-backend.resolver.ts new file mode 100644 index 0000000..f5c7e30 --- /dev/null +++ b/src/domain/backup/infrastructure/adapters/storage/backends/b2-backend.resolver.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; + +import { StorageConfig } from '@domain/config/domain/storage-config.model'; +import { + ResticBackendDescriptor, + ResticBackendResolver, + requireStorageConfig, +} from './restic-backend.resolver'; + +@Injectable() +export class B2BackendResolver implements ResticBackendResolver { + resolve(storage: StorageConfig): ResticBackendDescriptor { + if (!storage.repository.includes(':')) { + throw new Error( + `Storage repository "${storage.repository}" is not a valid b2 target — ` + + 'expected ":", e.g. "my-bucket:backups/myproject". ' + + 'Note this differs from the s3 backend, which uses "/".', + ); + } + + return { + repository: `b2:${storage.repository}`, + env: { + B2_ACCOUNT_ID: requireStorageConfig(storage, 'account_id'), + B2_ACCOUNT_KEY: requireStorageConfig(storage, 'account_key'), + }, + }; + } +} diff --git a/src/domain/backup/infrastructure/adapters/storage/backends/local-backend.resolver.ts b/src/domain/backup/infrastructure/adapters/storage/backends/local-backend.resolver.ts new file mode 100644 index 0000000..6c8da21 --- /dev/null +++ b/src/domain/backup/infrastructure/adapters/storage/backends/local-backend.resolver.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; + +import { StorageConfig } from '@domain/config/domain/storage-config.model'; +import { ResticBackendDescriptor, ResticBackendResolver } from './restic-backend.resolver'; + +@Injectable() +export class LocalBackendResolver implements ResticBackendResolver { + resolve(storage: StorageConfig): ResticBackendDescriptor { + return { + repository: storage.repository, + env: {}, + }; + } +} diff --git a/src/domain/backup/infrastructure/adapters/storage/backends/rclone-backend.resolver.ts b/src/domain/backup/infrastructure/adapters/storage/backends/rclone-backend.resolver.ts new file mode 100644 index 0000000..5ce2fb6 --- /dev/null +++ b/src/domain/backup/infrastructure/adapters/storage/backends/rclone-backend.resolver.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { StorageConfig } from '@domain/config/domain/storage-config.model'; +import { ResticBackendDescriptor, ResticBackendResolver } from './restic-backend.resolver'; + +@Injectable() +export class RcloneBackendResolver implements ResticBackendResolver { + constructor(private readonly configService: ConfigService) {} + + resolve(storage: StorageConfig): ResticBackendDescriptor { + if (!storage.repository.includes(':')) { + throw new Error( + `Storage repository "${storage.repository}" is not a valid rclone target — ` + + 'expected ":", e.g. "gdrive:backups/myproject".', + ); + } + + const configPath = + storage.config.config_path ?? this.configService.get('RCLONE_CONFIG_PATH', ''); + + return { + repository: `rclone:${storage.repository}`, + env: configPath ? { RCLONE_CONFIG: configPath } : {}, + }; + } +} diff --git a/src/domain/backup/infrastructure/adapters/storage/backends/restic-backend.registry.ts b/src/domain/backup/infrastructure/adapters/storage/backends/restic-backend.registry.ts new file mode 100644 index 0000000..df803e0 --- /dev/null +++ b/src/domain/backup/infrastructure/adapters/storage/backends/restic-backend.registry.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; + +import { StorageBackendType } from '@domain/config/domain/storage-config.model'; +import { ResticBackendResolver } from './restic-backend.resolver'; +import { SftpBackendResolver } from './sftp-backend.resolver'; +import { S3BackendResolver } from './s3-backend.resolver'; +import { B2BackendResolver } from './b2-backend.resolver'; +import { RcloneBackendResolver } from './rclone-backend.resolver'; +import { LocalBackendResolver } from './local-backend.resolver'; + +@Injectable() +export class ResticBackendRegistry { + private readonly resolvers: Record; + + constructor( + sftp: SftpBackendResolver, + s3: S3BackendResolver, + b2: B2BackendResolver, + rclone: RcloneBackendResolver, + local: LocalBackendResolver, + ) { + this.resolvers = { sftp, s3, b2, rclone, local }; + } + + resolve(type: StorageBackendType): ResticBackendResolver { + const resolver = this.resolvers[type]; + + if (!resolver) { + throw new Error( + `No storage backend registered for type: ${type} ` + + `(expected one of: ${Object.keys(this.resolvers).join(', ')})`, + ); + } + + return resolver; + } +} diff --git a/src/domain/backup/infrastructure/adapters/storage/backends/restic-backend.resolver.ts b/src/domain/backup/infrastructure/adapters/storage/backends/restic-backend.resolver.ts new file mode 100644 index 0000000..bf742c8 --- /dev/null +++ b/src/domain/backup/infrastructure/adapters/storage/backends/restic-backend.resolver.ts @@ -0,0 +1,37 @@ +import { + REQUIRED_STORAGE_CONFIG_KEYS, + StorageConfig, +} from '@domain/config/domain/storage-config.model'; + +export interface ResticBackendDescriptor { + readonly repository: string; + readonly env: Record; +} + +export interface ResticBackendResolver { + resolve(storage: StorageConfig): ResticBackendDescriptor; +} + +/** + * Reads a credential the backend declared as required. Keys come from + * REQUIRED_STORAGE_CONFIG_KEYS, which config validation reads too, so a key cannot be + * required at run time without also being rejected by `config validate`. + */ +export function requireStorageConfig(storage: StorageConfig, key: string): string { + if (!REQUIRED_STORAGE_CONFIG_KEYS[storage.type].includes(key)) { + throw new Error( + `Storage type "${storage.type}" reads config.${key} but does not declare it required — ` + + 'add it to REQUIRED_STORAGE_CONFIG_KEYS so config validate rejects it too.', + ); + } + + const value = storage.config[key]; + + if (!value) { + throw new Error( + `Storage type "${storage.type}" requires config.${key} — set it under storage.config in projects.yml.`, + ); + } + + return value; +} diff --git a/src/domain/backup/infrastructure/adapters/storage/backends/s3-backend.resolver.ts b/src/domain/backup/infrastructure/adapters/storage/backends/s3-backend.resolver.ts new file mode 100644 index 0000000..57f7ef6 --- /dev/null +++ b/src/domain/backup/infrastructure/adapters/storage/backends/s3-backend.resolver.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; + +import { StorageConfig } from '@domain/config/domain/storage-config.model'; +import { + ResticBackendDescriptor, + ResticBackendResolver, + requireStorageConfig, +} from './restic-backend.resolver'; + +@Injectable() +export class S3BackendResolver implements ResticBackendResolver { + resolve(storage: StorageConfig): ResticBackendDescriptor { + const endpoint = requireStorageConfig(storage, 'endpoint').replace(/\/+$/, ''); + const region = storage.config.region; + + // The sftp examples use absolute-looking paths, so a leading slash carried over + // during migration is common — it would otherwise yield "endpoint//bucket/path". + const bucketPath = storage.repository.replace(/^\/+/, ''); + + return { + repository: `s3:${endpoint}/${bucketPath}`, + env: { + AWS_ACCESS_KEY_ID: requireStorageConfig(storage, 'access_key_id'), + AWS_SECRET_ACCESS_KEY: requireStorageConfig(storage, 'secret_access_key'), + ...(region ? { AWS_DEFAULT_REGION: region } : {}), + }, + }; + } +} diff --git a/src/domain/backup/infrastructure/adapters/storage/backends/sftp-backend.resolver.ts b/src/domain/backup/infrastructure/adapters/storage/backends/sftp-backend.resolver.ts new file mode 100644 index 0000000..d7f932e --- /dev/null +++ b/src/domain/backup/infrastructure/adapters/storage/backends/sftp-backend.resolver.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { StorageConfig } from '@domain/config/domain/storage-config.model'; +import { ResticBackendDescriptor, ResticBackendResolver } from './restic-backend.resolver'; + +@Injectable() +export class SftpBackendResolver implements ResticBackendResolver { + constructor(private readonly configService: ConfigService) {} + + resolve(storage: StorageConfig): ResticBackendDescriptor { + const host = this.configService.getOrThrow('HETZNER_SSH_HOST'); + const user = this.configService.getOrThrow('HETZNER_SSH_USER'); + const keyPath = this.configService.getOrThrow('HETZNER_SSH_KEY_PATH'); + const port = parseInt(String(this.configService.get('HETZNER_SSH_PORT', '22')), 10); + + return { + repository: `sftp:${user}@${host}:${storage.repository}`, + env: { + RESTIC_SSH_COMMAND: `ssh -i "${keyPath}" -p ${port} -o StrictHostKeyChecking=accept-new`, + }, + }; + } +} diff --git a/src/domain/backup/infrastructure/adapters/storage/restic-storage.adapter.ts b/src/domain/backup/infrastructure/adapters/storage/restic-storage.adapter.ts index 9c3810b..e11f814 100644 --- a/src/domain/backup/infrastructure/adapters/storage/restic-storage.adapter.ts +++ b/src/domain/backup/infrastructure/adapters/storage/restic-storage.adapter.ts @@ -32,22 +32,12 @@ interface ResticForgetGroup { } export class ResticStorageAdapter implements RemoteStoragePort { - private readonly repository: string; - - private readonly sshCommand: string; - constructor( - repositoryPath: string, + private readonly repository: string, private readonly password: string, - sshHost: string, - sshUser: string, - sshKeyPath: string, + private readonly backendEnv: Record, private readonly projectName: string, - sshPort = 22, - ) { - this.repository = `sftp:${sshUser}@${sshHost}:${repositoryPath}`; - this.sshCommand = `ssh -i "${sshKeyPath}" -p ${sshPort} -o StrictHostKeyChecking=accept-new`; - } + ) {} async sync(paths: string[], options: SyncOptions): Promise { const args = ['backup', ...paths]; @@ -161,11 +151,22 @@ export class ResticStorageAdapter implements RemoteStoragePort { await safeExecFile('restic', ['unlock'], { env: this.getEnv() }); } + /** + * Reads the repository config, which proves reachability, credentials and the + * repository password in one round trip. Throws with restic's own message. + */ + async checkConnectivity(): Promise { + await safeExecFile('restic', ['cat', 'config'], { + env: this.getEnv(), + timeout: 30000, + }); + } + private getEnv(): Record { return { RESTIC_REPOSITORY: this.repository, RESTIC_PASSWORD: this.password, - RESTIC_SSH_COMMAND: this.sshCommand, + ...this.backendEnv, }; } diff --git a/src/domain/backup/infrastructure/adapters/storage/restic-storage.factory.ts b/src/domain/backup/infrastructure/adapters/storage/restic-storage.factory.ts index 9859290..d336039 100644 --- a/src/domain/backup/infrastructure/adapters/storage/restic-storage.factory.ts +++ b/src/domain/backup/infrastructure/adapters/storage/restic-storage.factory.ts @@ -4,36 +4,29 @@ import { ConfigService } from '@nestjs/config'; import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remote-storage-factory.port'; import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; +import { ResticBackendRegistry } from './backends/restic-backend.registry'; import { ResticStorageAdapter } from './restic-storage.adapter'; @Injectable() export class ResticStorageFactory implements RemoteStorageFactoryPort { - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + private readonly backendRegistry: ResticBackendRegistry, + ) {} create(config: ProjectConfig): RemoteStoragePort { - const sshHost = this.configService.getOrThrow('HETZNER_SSH_HOST'); - const sshUser = this.configService.getOrThrow('HETZNER_SSH_USER'); - const sshKeyPath = this.configService.getOrThrow('HETZNER_SSH_KEY_PATH'); - const sshPort = parseInt(String(this.configService.get('HETZNER_SSH_PORT', '22')), 10); const globalPassword = this.configService.get('RESTIC_PASSWORD', ''); - - const password = config.restic.password || globalPassword; + const password = config.storage.password || globalPassword; if (!password) { throw new Error( `Restic password not configured for project "${config.name}". ` + - 'Set restic.password in projects.yml or RESTIC_PASSWORD in .env.', + 'Set storage.password in projects.yml or RESTIC_PASSWORD in .env.', ); } - return new ResticStorageAdapter( - config.restic.repositoryPath, - password, - sshHost, - sshUser, - sshKeyPath, - config.name, - sshPort, - ); + const backend = this.backendRegistry.resolve(config.storage.type).resolve(config.storage); + + return new ResticStorageAdapter(backend.repository, password, backend.env, config.name); } } diff --git a/src/domain/config/domain/project-config.model.ts b/src/domain/config/domain/project-config.model.ts index d228522..8e3266f 100644 --- a/src/domain/config/domain/project-config.model.ts +++ b/src/domain/config/domain/project-config.model.ts @@ -1,4 +1,5 @@ import { RetentionPolicy } from './retention-policy.model'; +import { StorageConfig } from './storage-config.model'; export interface ProjectConfigParams { readonly name: string; @@ -17,11 +18,7 @@ export interface ProjectConfigParams { } | null; readonly compression: { readonly enabled: boolean }; readonly assets: { readonly paths: readonly string[] }; - readonly restic: { - readonly repositoryPath: string; - readonly password: string; - readonly snapshotMode: 'combined' | 'separate'; - }; + readonly storage: StorageConfig; readonly retention: RetentionPolicy; readonly encryption: { readonly enabled: boolean; readonly type: string; readonly recipient: string } | null; readonly hooks: { readonly preBackup: string | null; readonly postBackup: string | null } | null; @@ -47,11 +44,7 @@ export class ProjectConfig { } | null; readonly compression: { readonly enabled: boolean }; readonly assets: { readonly paths: readonly string[] }; - readonly restic: { - readonly repositoryPath: string; - readonly password: string; - readonly snapshotMode: 'combined' | 'separate'; - }; + readonly storage: StorageConfig; readonly retention: RetentionPolicy; readonly encryption: { readonly enabled: boolean; @@ -72,7 +65,7 @@ export class ProjectConfig { this.database = params.database; this.compression = params.compression; this.assets = params.assets; - this.restic = params.restic; + this.storage = params.storage; this.retention = params.retention; this.encryption = params.encryption; this.hooks = params.hooks; diff --git a/src/domain/config/domain/storage-config.model.ts b/src/domain/config/domain/storage-config.model.ts new file mode 100644 index 0000000..682df04 --- /dev/null +++ b/src/domain/config/domain/storage-config.model.ts @@ -0,0 +1,27 @@ +export const STORAGE_BACKEND_TYPES = ['sftp', 's3', 'b2', 'rclone', 'local'] as const; + +export type StorageBackendType = (typeof STORAGE_BACKEND_TYPES)[number]; + +export const SNAPSHOT_MODES = ['combined', 'separate'] as const; + +export type SnapshotMode = (typeof SNAPSHOT_MODES)[number]; + +export interface StorageConfig { + readonly type: StorageBackendType; + readonly repository: string; + readonly password: string; + readonly snapshotMode: SnapshotMode; + readonly config: Record; +} + +/** + * Shared by config validation and the backend resolvers so the two cannot disagree + * about what a backend needs. + */ +export const REQUIRED_STORAGE_CONFIG_KEYS: Record = { + sftp: [], + s3: ['endpoint', 'access_key_id', 'secret_access_key'], + b2: ['account_id', 'account_key'], + rclone: [], + local: [], +}; diff --git a/src/domain/config/infrastructure/yaml-config-loader.adapter.ts b/src/domain/config/infrastructure/yaml-config-loader.adapter.ts index 7110ef6..a7d1f55 100644 --- a/src/domain/config/infrastructure/yaml-config-loader.adapter.ts +++ b/src/domain/config/infrastructure/yaml-config-loader.adapter.ts @@ -6,6 +6,13 @@ import * as yaml from 'js-yaml'; import { ConfigLoaderPort, ValidationResult } from '@domain/config/application/ports/config-loader.port'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { + REQUIRED_STORAGE_CONFIG_KEYS, + SNAPSHOT_MODES, + STORAGE_BACKEND_TYPES, + SnapshotMode, + StorageBackendType, +} from '@domain/config/domain/storage-config.model'; interface RawProjectEntry { name: string; @@ -24,7 +31,15 @@ interface RawProjectEntry { }; compression?: { enabled?: boolean }; assets?: { paths?: string[] }; - restic: { + storage?: { + type?: string; + repository?: string; + password?: string; + snapshot_mode?: string; + config?: Record; + }; + /** @deprecated Use `storage` with `type: sftp`. Normalized away at load time. */ + restic?: { repository_path: string; password?: string; snapshot_mode?: string; @@ -63,6 +78,7 @@ interface RawYamlConfig { export class YamlConfigLoaderAdapter implements ConfigLoaderPort { private readonly logger = new Logger(YamlConfigLoaderAdapter.name); private projects: ProjectConfig[] | null = null; + private readonly warnedLegacyProjects = new Set(); private readonly configPath: string; constructor(private readonly configService: ConfigService) { @@ -124,9 +140,8 @@ export class YamlConfigLoaderAdapter implements ConfigLoaderPort { if (!hasDb && !hasAssets) { errors.push(`Project "${resolved.name}": must have at least one of "database" or "assets"`); } - if (!resolved.restic) { - errors.push(`Project "${resolved.name}": missing required field: restic`); - } + errors.push(...this.validateStorage(resolved)); + if (!resolved.retention) { errors.push(`Project "${resolved.name}": missing required field: retention`); } @@ -164,9 +179,77 @@ export class YamlConfigLoaderAdapter implements ConfigLoaderPort { return { isValid: errors.length === 0, errors }; } + private validateStorage(entry: RawProjectEntry): string[] { + const errors: string[] = []; + const project = `Project "${entry.name}"`; + + if (entry.restic && entry.storage) { + errors.push(`${project}: has both "restic" and "storage" — keep only "storage"`); + return errors; + } + + const storage = entry.storage ?? this.legacyStorageView(entry); + + if (!storage) { + errors.push(`${project}: missing required field: storage`); + return errors; + } + + const type = storage.type ?? 'sftp'; + if (!STORAGE_BACKEND_TYPES.includes(type as StorageBackendType)) { + errors.push( + `${project}: invalid storage type "${type}" (expected one of: ${STORAGE_BACKEND_TYPES.join(', ')})`, + ); + return errors; + } + + if (!storage.repository) { + errors.push(`${project}: storage missing required field: repository`); + } + + if (storage.snapshot_mode && !SNAPSHOT_MODES.includes(storage.snapshot_mode as SnapshotMode)) { + errors.push( + `${project}: invalid snapshot_mode "${storage.snapshot_mode}" ` + + `(expected one of: ${SNAPSHOT_MODES.join(', ')})`, + ); + } + + for (const key of REQUIRED_STORAGE_CONFIG_KEYS[type as StorageBackendType]) { + if (!storage.config?.[key]) { + errors.push(`${project}: storage type "${type}" requires config.${key}`); + } + } + + if (type === 'sftp') { + for (const key of ['HETZNER_SSH_HOST', 'HETZNER_SSH_USER', 'HETZNER_SSH_KEY_PATH']) { + if (!this.configService.get(key)) { + errors.push(`${project}: storage type "sftp" requires ${key} in .env`); + } + } + } + + if (!storage.password && !this.configService.get('RESTIC_PASSWORD')) { + errors.push(`${project}: storage requires a password, or RESTIC_PASSWORD in .env`); + } + + return errors; + } + + private legacyStorageView(entry: RawProjectEntry): RawProjectEntry['storage'] | null { + if (!entry.restic) return null; + + return { + type: 'sftp', + repository: entry.restic.repository_path, + password: entry.restic.password, + snapshot_mode: entry.restic.snapshot_mode, + }; + } + reload(): void { this.logger.log('Reloading project configuration'); this.projects = null; + this.warnedLegacyProjects.clear(); this.loadAll(); } @@ -260,7 +343,9 @@ export class YamlConfigLoaderAdapter implements ConfigLoaderPort { } } - entry.restic.password ??= this.configService.get('RESTIC_PASSWORD') ?? undefined; + if (entry.storage) { + entry.storage.password ??= this.configService.get('RESTIC_PASSWORD') ?? undefined; + } if (!entry.compression) { entry.compression = { enabled: true }; @@ -269,13 +354,39 @@ export class YamlConfigLoaderAdapter implements ConfigLoaderPort { } } + /** + * Maps the deprecated `restic:` block onto `storage:` with `type: sftp`. + * Remove once no deployment carries a legacy config. + */ + private normalizeLegacyStorage(entry: RawProjectEntry): void { + const legacyStorage = this.legacyStorageView(entry); + if (!legacyStorage || entry.storage) return; + + // loadAll() re-reads the file on every call, and callers include the health probe + // on a 5-minute timer — warn once per project rather than forever. + if (!this.warnedLegacyProjects.has(entry.name)) { + this.warnedLegacyProjects.add(entry.name); + this.logger.warn( + `Project "${entry.name}": the "restic" config block is deprecated — rename it to "storage" ` + + 'with "type: sftp" and "repository" in place of "repository_path".', + ); + } + + entry.storage = legacyStorage; + } + private buildProjectConfig(raw: RawProjectEntry): ProjectConfig { const resolved = this.resolveEnvVarsInObject( raw as unknown as Record, ) as unknown as RawProjectEntry; + this.normalizeLegacyStorage(resolved); this.applyFallbacks(resolved); + if (!resolved.storage) { + throw new Error(`Project "${resolved.name}": missing required field: storage`); + } + const retention = new RetentionPolicy( resolved.retention.local_days, resolved.retention.keep_daily, @@ -302,10 +413,12 @@ export class YamlConfigLoaderAdapter implements ConfigLoaderPort { : null, compression: { enabled: resolved.compression?.enabled ?? true }, assets: { paths: resolved.assets?.paths ?? [] }, - restic: { - repositoryPath: resolved.restic.repository_path, - password: resolved.restic.password ?? '', - snapshotMode: (resolved.restic.snapshot_mode as 'combined' | 'separate') ?? 'combined', + storage: { + type: (resolved.storage.type as StorageBackendType) ?? 'sftp', + repository: resolved.storage.repository ?? '', + password: resolved.storage.password ?? '', + snapshotMode: (resolved.storage.snapshot_mode as SnapshotMode) ?? 'combined', + config: resolved.storage.config ?? {}, }, retention, encryption: resolved.encryption diff --git a/src/domain/config/presenters/cli/config.command.ts b/src/domain/config/presenters/cli/config.command.ts index ade797e..4f38635 100644 --- a/src/domain/config/presenters/cli/config.command.ts +++ b/src/domain/config/presenters/cli/config.command.ts @@ -3,6 +3,7 @@ import { Command, CommandRunner, SubCommand } from 'nest-commander'; import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; import { GpgKeyManagerPort } from '@domain/backup/application/ports/gpg-key-manager.port'; import { CONFIG_LOADER_PORT, GPG_KEY_MANAGER_PORT } from '@common/di/injection-tokens'; +import { maskSecretValues } from '@common/helpers/mask.util'; @SubCommand({ name: 'validate', description: 'Validate project configuration' }) export class ConfigValidateSubCommand extends CommandRunner { @@ -30,8 +31,16 @@ export class ConfigShowSubCommand extends CommandRunner { name: config.name, enabled: config.enabled, cron: config.cron, timeoutMinutes: config.timeoutMinutes, database: config.database ? { type: config.database.type, host: config.database.host, port: config.database.port, name: config.database.name, user: config.database.user, password: '********' } : null, compression: config.compression, assets: config.assets, - restic: { repositoryPath: config.restic.repositoryPath, password: '********', snapshotMode: config.restic.snapshotMode }, - retention: config.retention, encryption: config.encryption, hooks: config.hooks, verification: config.verification, notification: config.notification, + storage: { + type: config.storage.type, + repository: config.storage.repository, + password: '********', + snapshotMode: config.storage.snapshotMode, + config: maskSecretValues(config.storage.config), + }, + retention: config.retention, encryption: config.encryption, hooks: config.hooks, verification: config.verification, + notification: config.notification ? { type: config.notification.type, config: maskSecretValues(config.notification.config) } : null, + monitor: config.monitor ? { type: config.monitor.type, config: maskSecretValues(config.monitor.config) } : null, }; console.log(JSON.stringify(masked, null, 2)); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); process.exitCode = 1; } diff --git a/src/domain/health/application/ports/system-health.port.ts b/src/domain/health/application/ports/system-health.port.ts index d675cc9..76e9e0a 100644 --- a/src/domain/health/application/ports/system-health.port.ts +++ b/src/domain/health/application/ports/system-health.port.ts @@ -3,15 +3,6 @@ export interface DiskSpaceResult { readonly freeGb: number; } -export interface SshCheckConfig { - readonly host: string; - readonly port: number; - readonly user: string; - readonly keyPath: string; -} - export interface SystemHealthPort { checkDiskSpace(path: string, minFreeGb: number): Promise; - checkSshConnectivity(config: SshCheckConfig): Promise; - checkSshAuthentication(keyPath: string): Promise; } diff --git a/src/domain/health/application/use-cases/check-health/check-health.use-case.ts b/src/domain/health/application/use-cases/check-health/check-health.use-case.ts index e2622e6..92c4ac0 100644 --- a/src/domain/health/application/use-cases/check-health/check-health.use-case.ts +++ b/src/domain/health/application/use-cases/check-health/check-health.use-case.ts @@ -1,49 +1,64 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AuditLogPort } from '@domain/audit/application/ports/audit-log.port'; import { HeartbeatMonitorPort } from '@domain/backup/application/ports/heartbeat-monitor.port'; -import { SystemHealthPort, SshCheckConfig } from '@domain/health/application/ports/system-health.port'; -import { HealthCheckResult } from '@domain/audit/domain/health-check-result.model'; -import { AUDIT_LOG_PORT, SYSTEM_HEALTH_PORT, HEARTBEAT_MONITOR_PORT } from '@common/di/injection-tokens'; +import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remote-storage-factory.port'; +import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; +import { ProjectConfig } from '@domain/config/domain/project-config.model'; +import { SystemHealthPort } from '@domain/health/application/ports/system-health.port'; +import { HealthCheckResult, StorageHealthCheck } from '@domain/audit/domain/health-check-result.model'; +import { ClockPort } from '@common/clock/clock.port'; +import { + AUDIT_LOG_PORT, + CLOCK_PORT, + CONFIG_LOADER_PORT, + HEARTBEAT_MONITOR_PORT, + REMOTE_STORAGE_FACTORY, + SYSTEM_HEALTH_PORT, +} from '@common/di/injection-tokens'; + +interface StorageCheckCache { + readonly checkedAtMs: number; + readonly checks: StorageHealthCheck[]; +} @Injectable() export class CheckHealthUseCase { + private readonly logger = new Logger(CheckHealthUseCase.name); + + private storageCache: StorageCheckCache | null = null; + constructor( @Inject(AUDIT_LOG_PORT) private readonly auditLog: AuditLogPort, @Inject(SYSTEM_HEALTH_PORT) private readonly systemHealth: SystemHealthPort, @Inject(HEARTBEAT_MONITOR_PORT) private readonly heartbeatMonitor: HeartbeatMonitorPort, + @Inject(CONFIG_LOADER_PORT) private readonly configLoader: ConfigLoaderPort, + @Inject(REMOTE_STORAGE_FACTORY) private readonly storageFactory: RemoteStorageFactoryPort, + @Inject(CLOCK_PORT) private readonly clock: ClockPort, private readonly configService: ConfigService, ) {} async execute(): Promise { const uptime = process.uptime(); const minFreeGb = this.configService.get('HEALTH_DISK_MIN_FREE_GB', 5); - const sshConfig = this.buildSshConfig(); const isKumaConfigured = !!this.configService.get('UPTIME_KUMA_BASE_URL'); - const [auditDbConnected, diskResult, sshConnected, sshAuthenticated, kumaConnected] = - await Promise.all([ - this.checkAuditDb(), - this.systemHealth.checkDiskSpace('/', minFreeGb), - sshConfig ? this.systemHealth.checkSshConnectivity(sshConfig) : Promise.resolve(false), - sshConfig?.keyPath ? this.systemHealth.checkSshAuthentication(sshConfig.keyPath) : Promise.resolve(false), - isKumaConfigured ? this.heartbeatMonitor.checkConnectivity() : Promise.resolve(false), - ]); - - const isSshConfigured = sshConfig !== null; + const [auditDbConnected, diskResult, storageChecks, kumaConnected] = await Promise.all([ + this.checkAuditDb(), + this.systemHealth.checkDiskSpace('/', minFreeGb), + this.checkStorageBackends(), + isKumaConfigured ? this.heartbeatMonitor.checkConnectivity() : Promise.resolve(false), + ]); - return new HealthCheckResult( + return new HealthCheckResult({ auditDbConnected, - diskResult.available, - diskResult.freeGb, - sshConnected, - sshAuthenticated, - sshConnected && sshAuthenticated, + diskSpaceAvailable: diskResult.available, + diskFreeGb: diskResult.freeGb, + storageChecks, uptime, - isSshConfigured, - kumaConnected, - isKumaConfigured, - ); + uptimeKumaConnected: kumaConnected, + uptimeKumaConfigured: isKumaConfigured, + }); } private async checkAuditDb(): Promise { @@ -55,15 +70,48 @@ export class CheckHealthUseCase { } } - private buildSshConfig(): SshCheckConfig | null { - const host = this.configService.get('HETZNER_SSH_HOST', ''); - if (!host) return null; + /** + * Cached because Docker's HEALTHCHECK polls /health every 30s: an uncached probe + * would bill a restic round trip per project per poll against B2/Drive, and spawn + * an `rclone serve restic` subprocess each time. + */ + private async checkStorageBackends(): Promise { + const ttlSeconds = this.configService.get('HEALTH_STORAGE_CHECK_TTL_SECONDS', 300); + const nowMs = this.clock.now().getTime(); + + if (this.storageCache && nowMs - this.storageCache.checkedAtMs < ttlSeconds * 1000) { + return this.storageCache.checks; + } + + const checks = await this.probeStorageBackends(); + this.storageCache = { checkedAtMs: nowMs, checks }; + + return checks; + } + + private async probeStorageBackends(): Promise { + let projects: ProjectConfig[]; + try { + projects = this.configLoader.loadAll().filter((project) => project.enabled); + } catch (error) { + this.logger.error('Could not read project config for storage health check', error); + return []; + } - return { - host, - port: this.configService.get('HETZNER_SSH_PORT', 22), - user: this.configService.get('HETZNER_SSH_USER', ''), - keyPath: this.configService.get('HETZNER_SSH_KEY_PATH', ''), - }; + return Promise.all(projects.map((project) => this.probeProject(project))); + } + + private async probeProject(project: ProjectConfig): Promise { + const check = { project: project.name, backendType: project.storage.type }; + + try { + const storage = this.storageFactory.create(project); + await storage.checkConnectivity(); + return { ...check, reachable: true, error: null }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Storage check failed for ${project.name}: ${message}`); + return { ...check, reachable: false, error: message }; + } } } diff --git a/src/domain/health/application/use-cases/check-liveness/check-liveness.use-case.ts b/src/domain/health/application/use-cases/check-liveness/check-liveness.use-case.ts new file mode 100644 index 0000000..4734f7d --- /dev/null +++ b/src/domain/health/application/use-cases/check-liveness/check-liveness.use-case.ts @@ -0,0 +1,45 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AuditLogPort } from '@domain/audit/application/ports/audit-log.port'; +import { SystemHealthPort } from '@domain/health/application/ports/system-health.port'; +import { LivenessResult } from '@domain/health/domain/liveness-result.model'; +import { AUDIT_LOG_PORT, SYSTEM_HEALTH_PORT } from '@common/di/injection-tokens'; + +/** + * Container-local health only. Deliberately excludes remote storage: this backs the + * Docker HEALTHCHECK, and restarting the container cannot fix a Backblaze or Drive + * outage. Remote reachability belongs to CheckHealthUseCase. + */ +@Injectable() +export class CheckLivenessUseCase { + constructor( + @Inject(AUDIT_LOG_PORT) private readonly auditLog: AuditLogPort, + @Inject(SYSTEM_HEALTH_PORT) private readonly systemHealth: SystemHealthPort, + private readonly configService: ConfigService, + ) {} + + async execute(): Promise { + const minFreeGb = this.configService.get('HEALTH_DISK_MIN_FREE_GB', 5); + + const [auditDbConnected, diskResult] = await Promise.all([ + this.checkAuditDb(), + this.systemHealth.checkDiskSpace('/', minFreeGb), + ]); + + return new LivenessResult({ + auditDbConnected, + diskSpaceAvailable: diskResult.available, + diskFreeGb: diskResult.freeGb, + uptime: process.uptime(), + }); + } + + private async checkAuditDb(): Promise { + try { + await this.auditLog.findSince(new Date()); + return true; + } catch { + return false; + } + } +} diff --git a/src/domain/health/domain/liveness-result.model.ts b/src/domain/health/domain/liveness-result.model.ts new file mode 100644 index 0000000..2a834c4 --- /dev/null +++ b/src/domain/health/domain/liveness-result.model.ts @@ -0,0 +1,24 @@ +export interface LivenessResultParams { + readonly auditDbConnected: boolean; + readonly diskSpaceAvailable: boolean; + readonly diskFreeGb: number; + readonly uptime: number; +} + +export class LivenessResult { + readonly auditDbConnected: boolean; + readonly diskSpaceAvailable: boolean; + readonly diskFreeGb: number; + readonly uptime: number; + + constructor(params: LivenessResultParams) { + this.auditDbConnected = params.auditDbConnected; + this.diskSpaceAvailable = params.diskSpaceAvailable; + this.diskFreeGb = params.diskFreeGb; + this.uptime = params.uptime; + } + + isAlive(): boolean { + return this.auditDbConnected && this.diskSpaceAvailable; + } +} diff --git a/src/domain/health/health.module.ts b/src/domain/health/health.module.ts index 2e7c90e..82456ae 100644 --- a/src/domain/health/health.module.ts +++ b/src/domain/health/health.module.ts @@ -4,6 +4,7 @@ import { AuditModule } from '@domain/audit/audit.module'; import { BackupModule } from '@domain/backup/backup.module'; import { SystemHealthAdapter } from './infrastructure/adapters/system-health.adapter'; import { CheckHealthUseCase } from './application/use-cases/check-health/check-health.use-case'; +import { CheckLivenessUseCase } from './application/use-cases/check-liveness/check-liveness.use-case'; import { HealthCommand } from './presenters/cli/health.command'; import { HealthController } from './presenters/http/health.controller'; import { SYSTEM_HEALTH_PORT } from '@common/di/injection-tokens'; @@ -13,6 +14,7 @@ import { SYSTEM_HEALTH_PORT } from '@common/di/injection-tokens'; controllers: [HealthController], providers: [ CheckHealthUseCase, + CheckLivenessUseCase, HealthCommand, { provide: SYSTEM_HEALTH_PORT, useClass: SystemHealthAdapter }, ], diff --git a/src/domain/health/infrastructure/adapters/system-health.adapter.ts b/src/domain/health/infrastructure/adapters/system-health.adapter.ts index d031b54..f8b17c1 100644 --- a/src/domain/health/infrastructure/adapters/system-health.adapter.ts +++ b/src/domain/health/infrastructure/adapters/system-health.adapter.ts @@ -1,11 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { SystemHealthPort, DiskSpaceResult, SshCheckConfig } from '../../application/ports/system-health.port'; +import { Injectable } from '@nestjs/common'; +import { SystemHealthPort, DiskSpaceResult } from '../../application/ports/system-health.port'; import { safeExecFile } from '@common/helpers/child-process.util'; @Injectable() export class SystemHealthAdapter implements SystemHealthPort { - private readonly logger = new Logger(SystemHealthAdapter.name); - async checkDiskSpace(path: string, minFreeGb: number): Promise { try { // BusyBox (Alpine) df doesn't support -BG/--output — use POSIX-compatible format @@ -21,42 +19,4 @@ export class SystemHealthAdapter implements SystemHealthPort { return { available: false, freeGb: 0 }; } } - - async checkSshConnectivity(config: SshCheckConfig): Promise { - try { - // Hetzner Storage Boxes have a restricted shell — no echo/ls/true available. - // Use SSH's own exit-on-connect (-o LogLevel=error suppresses banners) to - // verify connectivity without executing a remote command. - await safeExecFile('ssh', [ - '-i', config.keyPath, - '-p', String(config.port), - '-o', 'StrictHostKeyChecking=accept-new', - '-o', 'ConnectTimeout=5', - '-o', 'BatchMode=yes', - '-o', 'LogLevel=error', - `${config.user}@${config.host}`, - 'exit', - ], { timeout: 15000 }); - return true; - } catch (error) { - const message = (error as Error).message; - // "Command not found" means SSH connected but the restricted shell rejected - // the command — connectivity is actually fine - if (message.includes('Command not found')) { - return true; - } - this.logger.warn(`SSH connectivity check failed: ${message}`); - return false; - } - } - - async checkSshAuthentication(keyPath: string): Promise { - if (!keyPath) return false; - try { - const { stdout } = await safeExecFile('ssh-keygen', ['-l', '-f', keyPath], { timeout: 5000 }); - return stdout.length > 0; - } catch { - return false; - } - } } diff --git a/src/domain/health/presenters/cli/health.command.ts b/src/domain/health/presenters/cli/health.command.ts index 10009e1..0ddd853 100644 --- a/src/domain/health/presenters/cli/health.command.ts +++ b/src/domain/health/presenters/cli/health.command.ts @@ -20,9 +20,13 @@ export class HealthCommand extends CommandRunner { console.log(''); this.printCheck('Audit DB', result.auditDbConnected); this.printCheck('Disk space', result.diskSpaceAvailable, `${result.diskFreeGb} GB free`); - this.printCheck('SSH connection', result.sshConnected); - this.printCheck('SSH auth', result.sshAuthenticated); - this.printCheck('Restic repos', result.resticReposHealthy); + for (const check of result.storageChecks) { + this.printCheck( + `Storage: ${check.project} (${check.backendType})`, + check.reachable, + check.error ?? undefined, + ); + } if (result.uptimeKumaConfigured) { this.printCheck('Uptime Kuma', result.uptimeKumaConnected); } diff --git a/src/domain/health/presenters/http/health.controller.ts b/src/domain/health/presenters/http/health.controller.ts index db6be18..7da5ebe 100644 --- a/src/domain/health/presenters/http/health.controller.ts +++ b/src/domain/health/presenters/http/health.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common'; import { CheckHealthUseCase } from '@domain/health/application/use-cases/check-health/check-health.use-case'; +import { CheckLivenessUseCase } from '@domain/health/application/use-cases/check-liveness/check-liveness.use-case'; import { HealthCheckResult } from '@domain/audit/domain/health-check-result.model'; interface HealthResponse { @@ -7,16 +8,51 @@ interface HealthResponse { checks: { auditDb: boolean; diskSpace: { available: boolean; freeGb: number }; - ssh: { connected: boolean; authenticated: boolean }; - resticRepos: boolean; + storage: Array<{ project: string; backendType: string; reachable: boolean; error?: string }>; uptimeKuma?: { configured: boolean; connected: boolean }; }; uptime: number; } +interface LivenessResponse { + status: 'healthy' | 'unhealthy'; + checks: { + auditDb: boolean; + diskSpace: { available: boolean; freeGb: number }; + }; + uptime: number; +} + @Controller('health') export class HealthController { - constructor(private readonly healthUseCase: CheckHealthUseCase) {} + constructor( + private readonly healthUseCase: CheckHealthUseCase, + private readonly livenessUseCase: CheckLivenessUseCase, + ) {} + + /** + * Backs the Docker HEALTHCHECK. Never touches remote storage — a container restart + * cannot fix a remote outage, and a storage probe can outlast the healthcheck timeout. + */ + @Get('live') + async live(): Promise { + const result = await this.livenessUseCase.execute(); + + const body: LivenessResponse = { + status: result.isAlive() ? 'healthy' : 'unhealthy', + checks: { + auditDb: result.auditDbConnected, + diskSpace: { available: result.diskSpaceAvailable, freeGb: result.diskFreeGb }, + }, + uptime: result.uptime, + }; + + if (!result.isAlive()) { + throw new HttpException(body, HttpStatus.SERVICE_UNAVAILABLE); + } + + return body; + } @Get() async check(): Promise { @@ -30,11 +66,12 @@ export class HealthController { available: result.diskSpaceAvailable, freeGb: result.diskFreeGb, }, - ssh: { - connected: result.sshConnected, - authenticated: result.sshAuthenticated, - }, - resticRepos: result.resticReposHealthy, + storage: result.storageChecks.map((check) => ({ + project: check.project, + backendType: check.backendType, + reachable: check.reachable, + ...(check.error ? { error: check.error } : {}), + })), ...(result.uptimeKumaConfigured && { uptimeKuma: { configured: result.uptimeKumaConfigured, diff --git a/test/integration/audit/typeorm-audit-log.integration.spec.ts b/test/integration/audit/typeorm-audit-log.integration.spec.ts index a0b5247..c958bf7 100644 --- a/test/integration/audit/typeorm-audit-log.integration.spec.ts +++ b/test/integration/audit/typeorm-audit-log.integration.spec.ts @@ -13,12 +13,12 @@ jest.setTimeout(30000); function buildBackupResult(overrides: Partial = {}): BackupResult { return new BackupResult({ runId: uuidv4(), - projectName: 'vinsware', + projectName: 'vinelab', status: BackupStatus.Success, currentStage: BackupStage.NotifyResult, startedAt: new Date('2026-03-18T02:00:00Z'), completedAt: new Date('2026-03-18T02:05:00Z'), - dumpResult: { filePath: '/data/backups/vinsware/dump.sql.gz', sizeBytes: 1024000, durationMs: 5000 }, + dumpResult: { filePath: '/data/backups/vinelab/dump.sql.gz', sizeBytes: 1024000, durationMs: 5000 }, syncResult: { snapshotId: 'snap-abc123', filesNew: 2, filesChanged: 1, bytesAdded: 512000, durationMs: 8000 }, pruneResult: { snapshotsRemoved: 1, spaceFreed: '100MB' }, cleanupResult: { filesRemoved: 3, spaceFreed: 2048 }, @@ -118,13 +118,13 @@ describe('TypeormAuditLogRepository (integration)', () => { describe('startRun', () => { it('should create a record with status=started', async () => { - const runId = await adapter.startRun('vinsware'); + const runId = await adapter.startRun('vinelab'); expect(runId).toBeDefined(); expect(typeof runId).toBe('string'); expect(repository.create).toHaveBeenCalledWith( expect.objectContaining({ - projectName: 'vinsware', + projectName: 'vinelab', status: BackupStatus.Started, currentStage: BackupStage.NotifyStarted, }), @@ -133,7 +133,7 @@ describe('TypeormAuditLogRepository (integration)', () => { }); it('should assign a UUID as the runId', async () => { - const runId = await adapter.startRun('vinsware'); + const runId = await adapter.startRun('vinelab'); // UUID v4 format expect(runId).toMatch( @@ -144,7 +144,7 @@ describe('TypeormAuditLogRepository (integration)', () => { describe('trackProgress', () => { it('should update current_stage on the record', async () => { - const runId = await adapter.startRun('vinsware'); + const runId = await adapter.startRun('vinelab'); await adapter.trackProgress(runId, BackupStage.Dump); @@ -154,7 +154,7 @@ describe('TypeormAuditLogRepository (integration)', () => { }); it('should track multiple stages sequentially', async () => { - const runId = await adapter.startRun('vinsware'); + const runId = await adapter.startRun('vinelab'); await adapter.trackProgress(runId, BackupStage.Dump); await adapter.trackProgress(runId, BackupStage.Verify); @@ -169,7 +169,7 @@ describe('TypeormAuditLogRepository (integration)', () => { describe('finishRun', () => { it('should update all fields on the record', async () => { - const runId = await adapter.startRun('vinsware'); + const runId = await adapter.startRun('vinelab'); const result = buildBackupResult({ runId }); await adapter.finishRun(runId, result); @@ -189,7 +189,7 @@ describe('TypeormAuditLogRepository (integration)', () => { }); it('should persist dump and sync metadata', async () => { - const runId = await adapter.startRun('vinsware'); + const runId = await adapter.startRun('vinelab'); const result = buildBackupResult({ runId }); await adapter.finishRun(runId, result); @@ -209,7 +209,7 @@ describe('TypeormAuditLogRepository (integration)', () => { }); it('should handle failure result with error details', async () => { - const runId = await adapter.startRun('vinsware'); + const runId = await adapter.startRun('vinelab'); const result = buildBackupResult({ runId, status: BackupStatus.Failed, @@ -237,21 +237,21 @@ describe('TypeormAuditLogRepository (integration)', () => { describe('findByProject', () => { it('should return results ordered by started_at DESC', async () => { // Insert two runs - const runId1 = await adapter.startRun('vinsware'); + const runId1 = await adapter.startRun('vinelab'); const store = (repository as unknown as { _store: BackupLogRecord[] })._store; const entity1 = store.find((e) => e.id === runId1); if (entity1) entity1.startedAt = new Date('2026-03-17T02:00:00Z'); - const runId2 = await adapter.startRun('vinsware'); + const runId2 = await adapter.startRun('vinelab'); const entity2 = store.find((e) => e.id === runId2); if (entity2) entity2.startedAt = new Date('2026-03-18T02:00:00Z'); - const results = await adapter.findByProject('vinsware'); + const results = await adapter.findByProject('vinelab'); expect(results).toHaveLength(2); expect(repository.find).toHaveBeenCalledWith( expect.objectContaining({ - where: { projectName: 'vinsware' }, + where: { projectName: 'vinelab' }, order: { startedAt: 'DESC' }, take: 20, }), @@ -259,11 +259,11 @@ describe('TypeormAuditLogRepository (integration)', () => { }); it('should respect the limit parameter', async () => { - await adapter.startRun('vinsware'); - await adapter.startRun('vinsware'); - await adapter.startRun('vinsware'); + await adapter.startRun('vinelab'); + await adapter.startRun('vinelab'); + await adapter.startRun('vinelab'); - await adapter.findByProject('vinsware', 2); + await adapter.findByProject('vinelab', 2); expect(repository.find).toHaveBeenCalledWith( expect.objectContaining({ take: 2 }), @@ -273,7 +273,7 @@ describe('TypeormAuditLogRepository (integration)', () => { describe('findOrphaned', () => { it('should return records with status=started and no completed_at', async () => { - await adapter.startRun('vinsware'); + await adapter.startRun('vinelab'); await adapter.startRun('shopify'); const results = await adapter.findOrphaned(); diff --git a/test/integration/cli/cli-commands.integration.spec.ts b/test/integration/cli/cli-commands.integration.spec.ts index 5c9f2f0..67e5044 100644 --- a/test/integration/cli/cli-commands.integration.spec.ts +++ b/test/integration/cli/cli-commands.integration.spec.ts @@ -19,10 +19,11 @@ import { ConfigLoaderPort, ValidationResult } from '@domain/config/application/p import { BackupResult } from '@domain/backup/domain/backup-result.model'; import { BackupStage } from '@domain/backup/domain/value-objects/backup-stage.enum'; import { BackupStatus } from '@domain/backup/domain/value-objects/backup-status.enum'; -import { HealthCheckResult } from '@domain/audit/domain/health-check-result.model'; +import { buildHealthCheckResult } from '@test/support/health-check-result.builder'; import { SnapshotInfo } from '@domain/backup/domain/value-objects/snapshot-info.model'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; import { GpgKeyManagerPort } from '@domain/backup/application/ports/gpg-key-manager.port'; import { CONFIG_LOADER_PORT, GPG_KEY_MANAGER_PORT } from '@common/di/injection-tokens'; @@ -31,7 +32,7 @@ jest.setTimeout(30000); function buildResult(overrides: Partial = {}): BackupResult { return new BackupResult({ runId: 'run-1', - projectName: 'vinsware', + projectName: 'vinelab', status: BackupStatus.Success, currentStage: BackupStage.NotifyResult, startedAt: new Date('2026-03-18T02:00:00Z'), @@ -53,33 +54,25 @@ function buildResult(overrides: Partial = {}): BackupResult { } function buildTestConfig(): ProjectConfig { - return new ProjectConfig({ - name: 'vinsware', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, + return buildProjectConfig({ + name: 'vinelab', database: { type: 'postgres', host: 'localhost', port: 5432, - name: 'vinsware_prod', + name: 'vinelab_prod', user: 'user', password: 'pass', dumpTimeoutMinutes: null, }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { - repositoryPath: '/backups/vinsware', + storage: { + type: 'sftp', + repository: '/backups/vinelab', password: 'rpass', snapshotMode: 'combined', + config: {}, }, retention: new RetentionPolicy(7, 7, 4), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, }); } @@ -153,10 +146,10 @@ describe('CLI commands (integration)', () => { it('should trigger backup for a named project', async () => { mockOrchestrator.execute.mockResolvedValue([buildResult()]); - await CommandTestFactory.run(commandModule, ['run', 'vinsware']); + await CommandTestFactory.run(commandModule, ['run', 'vinelab']); expect(mockOrchestrator.execute).toHaveBeenCalledWith( - expect.objectContaining({ projectName: 'vinsware', isAll: false }), + expect.objectContaining({ projectName: 'vinelab', isAll: false }), ); }); @@ -172,14 +165,14 @@ describe('CLI commands (integration)', () => { it('should call getDryRunReport for --dry-run flag', async () => { mockOrchestrator.getDryRunReport.mockResolvedValue({ - projectName: 'vinsware', + projectName: 'vinelab', checks: [{ name: 'Config loaded', passed: true, message: 'OK' }], allPassed: true, }); - await CommandTestFactory.run(commandModule, ['run', 'vinsware', '--dry-run']); + await CommandTestFactory.run(commandModule, ['run', 'vinelab', '--dry-run']); - expect(mockOrchestrator.getDryRunReport).toHaveBeenCalledWith('vinsware', { verifyDump: undefined }); + expect(mockOrchestrator.getDryRunReport).toHaveBeenCalledWith('vinelab', { verifyDump: undefined }); expect(mockOrchestrator.execute).not.toHaveBeenCalled(); }); }); @@ -187,7 +180,7 @@ describe('CLI commands (integration)', () => { describe('health command', () => { it('should call health check and display results', async () => { mockHealthCheck.execute.mockResolvedValue( - new HealthCheckResult(true, true, 50, true, true, true, 3600), + buildHealthCheckResult(), ); await CommandTestFactory.run(commandModule, ['health']); @@ -198,7 +191,7 @@ describe('CLI commands (integration)', () => { it('should report unhealthy when audit DB is down', async () => { mockHealthCheck.execute.mockResolvedValue( - new HealthCheckResult(false, true, 50, true, true, true, 3600), + buildHealthCheckResult({ auditDbConnected: false }), ); await CommandTestFactory.run(commandModule, ['health']); @@ -218,7 +211,7 @@ describe('CLI commands (integration)', () => { it('should report configuration errors', async () => { mockConfigLoader.validate.mockReturnValue({ isValid: false, - errors: ['Project "vinsware": missing required field: cron'], + errors: ['Project "vinelab": missing required field: cron'], }); await CommandTestFactory.run(commandModule, ['config', 'validate']); @@ -233,26 +226,26 @@ describe('CLI commands (integration)', () => { new SnapshotInfo( 'abc123def456', '2026-03-18T02:05:00Z', - ['/data/backups/vinsware'], + ['/data/backups/vinelab'], 'backupctl', - ['project:vinsware', 'db:postgres'], + ['project:vinelab', 'db:postgres'], '512MB', ), ]); - await CommandTestFactory.run(commandModule, ['snapshots', 'vinsware']); + await CommandTestFactory.run(commandModule, ['snapshots', 'vinelab']); expect(mockSnapshotManagement.execute).toHaveBeenCalledWith( - expect.objectContaining({ projectName: 'vinsware' }), + expect.objectContaining({ projectName: 'vinelab' }), ); }); it('should display message when no snapshots found', async () => { mockSnapshotManagement.execute.mockResolvedValue([]); - await CommandTestFactory.run(commandModule, ['snapshots', 'vinsware']); + await CommandTestFactory.run(commandModule, ['snapshots', 'vinelab']); - expect(console.log).toHaveBeenCalledWith('No snapshots found for vinsware.'); + expect(console.log).toHaveBeenCalledWith('No snapshots found for vinelab.'); }); }); }); diff --git a/test/integration/config/yaml-config-loader.integration.spec.ts b/test/integration/config/yaml-config-loader.integration.spec.ts index 0680dbf..d04bddd 100644 --- a/test/integration/config/yaml-config-loader.integration.spec.ts +++ b/test/integration/config/yaml-config-loader.integration.spec.ts @@ -10,21 +10,21 @@ jest.setTimeout(30000); function buildTestYaml(overrides: Record = {}): string { const project = { - name: 'vinsware', + name: 'vinelab', enabled: true, cron: '0 2 * * *', timeout_minutes: 30, database: { type: 'postgres', - host: 'db.vinsware.test', + host: 'db.vinelab.test', port: 5432, - name: 'vinsware_prod', - user: 'vinsware_user', - password: '${VINSWARE_DB_PASSWORD}', + name: 'vinelab_prod', + user: 'vinelab_user', + password: '${VINELAB_DB_PASSWORD}', }, - assets: { paths: ['/data/vinsware/uploads'] }, + assets: { paths: ['/data/vinelab/uploads'] }, restic: { - repository_path: 'sftp:storage:/backups/vinsware', + repository_path: 'sftp:storage:/backups/vinelab', password: '${RESTIC_PASSWORD}', snapshot_mode: 'combined', }, @@ -44,18 +44,18 @@ function buildTestYaml(overrides: Record = {}): string { function buildMultiProjectYaml(): string { const projects = [ { - name: 'vinsware', + name: 'vinelab', cron: '0 2 * * *', database: { type: 'postgres', - host: 'db.vinsware.test', + host: 'db.vinelab.test', port: 5432, - name: 'vinsware_prod', - user: 'vinsware_user', + name: 'vinelab_prod', + user: 'vinelab_user', password: 'plain-pass', }, restic: { - repository_path: 'sftp:storage:/backups/vinsware', + repository_path: 'sftp:storage:/backups/vinelab', password: 'restic-pass', }, retention: { local_days: 7, keep_daily: 7, keep_weekly: 4 }, @@ -93,9 +93,16 @@ describe('YamlConfigLoaderAdapter (integration)', () => { } function createAdapter(envOverrides: Record = {}): YamlConfigLoaderAdapter { + const env: Record = { + HETZNER_SSH_HOST: 'storage.example.com', + HETZNER_SSH_USER: 'u123', + HETZNER_SSH_KEY_PATH: '/home/node/.ssh/id_ed25519', + ...envOverrides, + }; + const configService = { get: jest.fn((key: string, defaultValue?: string) => { - if (key in envOverrides) return envOverrides[key]; + if (key in env) return env[key]; return defaultValue; }), } as unknown as ConfigService; @@ -132,7 +139,7 @@ describe('YamlConfigLoaderAdapter (integration)', () => { describe('loadAll', () => { it('should load YAML and create ProjectConfig objects', () => { - setEnvVar('VINSWARE_DB_PASSWORD', 'test-secret-123'); + setEnvVar('VINELAB_DB_PASSWORD', 'test-secret-123'); setEnvVar('RESTIC_PASSWORD', 'restic-secret'); fs.writeFileSync(tempConfigPath, buildTestYaml()); @@ -141,9 +148,9 @@ describe('YamlConfigLoaderAdapter (integration)', () => { expect(result).toHaveLength(1); expect(result[0]).toBeInstanceOf(ProjectConfig); - expect(result[0].name).toBe('vinsware'); + expect(result[0].name).toBe('vinelab'); expect(result[0].database?.type).toBe('postgres'); - expect(result[0].database?.host).toBe('db.vinsware.test'); + expect(result[0].database?.host).toBe('db.vinelab.test'); expect(result[0].database?.port).toBe(5432); expect(result[0].cron).toBe('0 2 * * *'); expect(result[0].timeoutMinutes).toBe(30); @@ -152,7 +159,7 @@ describe('YamlConfigLoaderAdapter (integration)', () => { }); it('should resolve ${VAR_NAME} placeholders from environment', () => { - setEnvVar('VINSWARE_DB_PASSWORD', 'resolved-db-pass'); + setEnvVar('VINELAB_DB_PASSWORD', 'resolved-db-pass'); setEnvVar('RESTIC_PASSWORD', 'resolved-restic-pass'); fs.writeFileSync(tempConfigPath, buildTestYaml()); @@ -160,13 +167,13 @@ describe('YamlConfigLoaderAdapter (integration)', () => { const result = adapter.loadAll(); expect(result[0].database?.password).toBe('resolved-db-pass'); - expect(result[0].restic.password).toBe('resolved-restic-pass'); + expect(result[0].storage.password).toBe('resolved-restic-pass'); }); }); describe('env fallbacks', () => { it('should fall back to env defaults when notification block is missing', () => { - setEnvVar('VINSWARE_DB_PASSWORD', 'pass'); + setEnvVar('VINELAB_DB_PASSWORD', 'pass'); setEnvVar('RESTIC_PASSWORD', 'rpass'); fs.writeFileSync(tempConfigPath, buildTestYaml()); @@ -183,7 +190,7 @@ describe('YamlConfigLoaderAdapter (integration)', () => { }); it('should fall back to env defaults for missing encryption', () => { - setEnvVar('VINSWARE_DB_PASSWORD', 'pass'); + setEnvVar('VINELAB_DB_PASSWORD', 'pass'); setEnvVar('RESTIC_PASSWORD', 'rpass'); fs.writeFileSync(tempConfigPath, buildTestYaml()); @@ -205,7 +212,7 @@ describe('YamlConfigLoaderAdapter (integration)', () => { describe('validate', () => { it('should return isValid:true for valid config', () => { - setEnvVar('VINSWARE_DB_PASSWORD', 'pass'); + setEnvVar('VINELAB_DB_PASSWORD', 'pass'); setEnvVar('RESTIC_PASSWORD', 'rpass'); fs.writeFileSync(tempConfigPath, buildTestYaml()); @@ -217,7 +224,7 @@ describe('YamlConfigLoaderAdapter (integration)', () => { }); it('should return errors for unresolved env vars', () => { - delete process.env.VINSWARE_DB_PASSWORD; + delete process.env.VINELAB_DB_PASSWORD; delete process.env.RESTIC_PASSWORD; fs.writeFileSync(tempConfigPath, buildTestYaml()); @@ -225,11 +232,11 @@ describe('YamlConfigLoaderAdapter (integration)', () => { const result = adapter.validate(); expect(result.isValid).toBe(false); - expect(result.errors.some((e) => e.includes('VINSWARE_DB_PASSWORD'))).toBe(true); + expect(result.errors.some((e) => e.includes('VINELAB_DB_PASSWORD'))).toBe(true); }); it('should return error when monitor is missing type', () => { - setEnvVar('VINSWARE_DB_PASSWORD', 'pass'); + setEnvVar('VINELAB_DB_PASSWORD', 'pass'); setEnvVar('RESTIC_PASSWORD', 'rpass'); fs.writeFileSync(tempConfigPath, buildTestYaml({ monitor: { config: { push_token: 'tok-123' } }, @@ -243,7 +250,7 @@ describe('YamlConfigLoaderAdapter (integration)', () => { }); it('should return error when uptime-kuma monitor is missing push_token', () => { - setEnvVar('VINSWARE_DB_PASSWORD', 'pass'); + setEnvVar('VINELAB_DB_PASSWORD', 'pass'); setEnvVar('RESTIC_PASSWORD', 'rpass'); fs.writeFileSync(tempConfigPath, buildTestYaml({ monitor: { type: 'uptime-kuma', config: {} }, @@ -257,7 +264,7 @@ describe('YamlConfigLoaderAdapter (integration)', () => { }); it('should return error when uptime-kuma monitor is missing UPTIME_KUMA_BASE_URL', () => { - setEnvVar('VINSWARE_DB_PASSWORD', 'pass'); + setEnvVar('VINELAB_DB_PASSWORD', 'pass'); setEnvVar('RESTIC_PASSWORD', 'rpass'); fs.writeFileSync(tempConfigPath, buildTestYaml({ monitor: { type: 'uptime-kuma', config: { push_token: 'tok-123' } }, @@ -271,7 +278,7 @@ describe('YamlConfigLoaderAdapter (integration)', () => { }); it('should pass validation for valid uptime-kuma monitor config', () => { - setEnvVar('VINSWARE_DB_PASSWORD', 'pass'); + setEnvVar('VINELAB_DB_PASSWORD', 'pass'); setEnvVar('RESTIC_PASSWORD', 'rpass'); fs.writeFileSync(tempConfigPath, buildTestYaml({ monitor: { type: 'uptime-kuma', config: { push_token: 'tok-123' } }, @@ -290,11 +297,11 @@ describe('YamlConfigLoaderAdapter (integration)', () => { fs.writeFileSync(tempConfigPath, buildMultiProjectYaml()); const adapter = createAdapter(); - const vinsware = adapter.getProject('vinsware'); + const vinelab = adapter.getProject('vinelab'); const shopify = adapter.getProject('shopify-sync'); - expect(vinsware.name).toBe('vinsware'); - expect(vinsware.database?.type).toBe('postgres'); + expect(vinelab.name).toBe('vinelab'); + expect(vinelab.database?.type).toBe('postgres'); expect(shopify.name).toBe('shopify-sync'); expect(shopify.database?.type).toBe('mysql'); }); diff --git a/test/integration/flow/backup-flow.integration.spec.ts b/test/integration/flow/backup-flow.integration.spec.ts index 42d01bf..b20fcf7 100644 --- a/test/integration/flow/backup-flow.integration.spec.ts +++ b/test/integration/flow/backup-flow.integration.spec.ts @@ -31,6 +31,8 @@ import { SyncResult } from '@domain/backup/domain/value-objects/sync-result.mode import { PruneResult } from '@domain/backup/domain/value-objects/prune-result.model'; import { CleanupResult } from '@domain/backup/domain/value-objects/cleanup-result.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; +import { createMockRemoteStorage } from '@test/support/remote-storage.mock'; import { CacheInfo } from '@domain/backup/domain/value-objects/cache-info.model'; import { @@ -55,33 +57,26 @@ jest.setTimeout(30000); // ── Test project config ───────────────────────────────────────────────── function buildTestConfig(overrides: Partial = {}): ProjectConfig { - return new ProjectConfig({ - name: 'vinsware', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, + return buildProjectConfig({ + name: 'vinelab', database: { type: 'postgres', host: 'localhost', port: 5432, - name: 'vinsware_prod', - user: 'vinsware_user', + name: 'vinelab_prod', + user: 'vinelab_user', password: 'test-pass', dumpTimeoutMinutes: null, }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { - repositoryPath: 'sftp:storage:/backups/vinsware', + storage: { + type: 'sftp', + repository: 'sftp:storage:/backups/vinelab', password: 'restic-pass', snapshotMode: 'combined', + config: {}, }, retention: new RetentionPolicy(7, 7, 4, 3), - encryption: null, - hooks: null, - verification: { enabled: false }, notification: { type: 'slack', config: {} }, - monitor: null, ...overrides, }); } @@ -225,7 +220,7 @@ describe('RunBackupUseCase (integration flow)', () => { const configLoader: ConfigLoaderPort = { loadAll: () => [testConfig], getProject: (name: string) => { - if (name === 'vinsware') return testConfig; + if (name === 'vinelab') return testConfig; throw new Error(`Project "${name}" not found in configuration`); }, validate: (): ValidationResult => ({ isValid: true, errors: [] }), @@ -236,12 +231,12 @@ describe('RunBackupUseCase (integration flow)', () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'backupctl-flow-test-')); mockDumper = { - dump: jest.fn().mockResolvedValue(new DumpResult('/data/backups/vinsware/dump.sql.gz', 1024000, 5000)), + dump: jest.fn().mockResolvedValue(new DumpResult('/data/backups/vinelab/dump.sql.gz', 1024000, 5000)), verify: jest.fn().mockResolvedValue(true), testConnection: jest.fn().mockResolvedValue(undefined), }; - mockStorage = { + mockStorage = createMockRemoteStorage({ sync: jest.fn().mockResolvedValue(new SyncResult('snap-abc123', 2, 1, 512000, 8000)), prune: jest.fn().mockResolvedValue(new PruneResult(1, '100MB')), listSnapshots: jest.fn().mockResolvedValue([]), @@ -250,7 +245,7 @@ describe('RunBackupUseCase (integration flow)', () => { getCacheInfo: jest.fn().mockResolvedValue({ totalSize: '10MB', location: '/tmp/cache' } as unknown as CacheInfo), clearCache: jest.fn().mockResolvedValue(undefined), unlock: jest.fn().mockResolvedValue(undefined), - }; + }); mockNotifier = { notifyStarted: jest.fn().mockResolvedValue(undefined), @@ -261,8 +256,8 @@ describe('RunBackupUseCase (integration flow)', () => { }; mockEncryptor = { - encrypt: jest.fn().mockResolvedValue('/data/backups/vinsware/dump.sql.gz.gpg'), - decrypt: jest.fn().mockResolvedValue('/data/backups/vinsware/dump.sql.gz'), + encrypt: jest.fn().mockResolvedValue('/data/backups/vinelab/dump.sql.gz.gpg'), + decrypt: jest.fn().mockResolvedValue('/data/backups/vinelab/dump.sql.gz'), }; mockHookExecutor = { @@ -320,14 +315,14 @@ describe('RunBackupUseCase (integration flow)', () => { // ── Happy path ────────────────────────────────────────────────────── it('should complete full backup flow: dump → sync → prune → cleanup → audit → notify', async () => { - const [result] = await orchestrator.execute(new RunBackupCommand({ projectName: 'vinsware' })); + const [result] = await orchestrator.execute(new RunBackupCommand({ projectName: 'vinelab' })); expect(result.status).toBe(BackupStatus.Success); - expect(result.projectName).toBe('vinsware'); + expect(result.projectName).toBe('vinelab'); expect(result.durationMs).toBeGreaterThanOrEqual(0); // Verify each step was called - expect(mockNotifier.notifyStarted).toHaveBeenCalledWith('vinsware'); + expect(mockNotifier.notifyStarted).toHaveBeenCalledWith('vinelab'); expect(mockDumper.dump).toHaveBeenCalled(); expect(mockStorage.sync).toHaveBeenCalled(); expect(mockStorage.prune).toHaveBeenCalled(); @@ -342,29 +337,29 @@ describe('RunBackupUseCase (integration flow)', () => { expect(auditLog.records[0].result).not.toBeNull(); // Verify lock was released - expect(backupLock.isLocked('vinsware')).toBe(false); + expect(backupLock.isLocked('vinelab')).toBe(false); }); // ── Lock prevents concurrent backup ──────────────────────────────── it('should prevent concurrent backup via lock', async () => { // Manually acquire the lock - await backupLock.acquire('vinsware'); + await backupLock.acquire('vinelab'); - await expect(orchestrator.execute(new RunBackupCommand({ projectName: 'vinsware' }))).rejects.toThrow( - 'Backup already in progress for vinsware', + await expect(orchestrator.execute(new RunBackupCommand({ projectName: 'vinelab' }))).rejects.toThrow( + 'Backup already in progress for vinelab', ); // Release for cleanup - await backupLock.release('vinsware'); + await backupLock.release('vinelab'); }); // ── Dry run ──────────────────────────────────────────────────────── it('should not execute backup steps during dry run', async () => { - const [result] = await orchestrator.execute(new RunBackupCommand({ projectName: 'vinsware', isDryRun: true })); + const [result] = await orchestrator.execute(new RunBackupCommand({ projectName: 'vinelab', isDryRun: true })); - expect(result.projectName).toBe('vinsware'); + expect(result.projectName).toBe('vinelab'); expect(result.runId).toBe('dry-run'); expect(result.status).toBe(BackupStatus.Success); @@ -384,10 +379,10 @@ describe('RunBackupUseCase (integration flow)', () => { if (callCount < 3) { throw new Error('pg_dump connection refused'); } - return new DumpResult('/data/backups/vinsware/dump.sql.gz', 1024000, 5000); + return new DumpResult('/data/backups/vinelab/dump.sql.gz', 1024000, 5000); }); - const [result] = await orchestrator.execute(new RunBackupCommand({ projectName: 'vinsware' })); + const [result] = await orchestrator.execute(new RunBackupCommand({ projectName: 'vinelab' })); expect(result.status).toBe(BackupStatus.Success); // First call + 2 retries = 3 total calls @@ -400,7 +395,7 @@ describe('RunBackupUseCase (integration flow)', () => { it('should fail after exhausting retries', async () => { mockDumper.dump.mockRejectedValue(new Error('pg_dump persistent failure')); - const [result] = await orchestrator.execute(new RunBackupCommand({ projectName: 'vinsware' })); + const [result] = await orchestrator.execute(new RunBackupCommand({ projectName: 'vinelab' })); expect(result.status).toBe(BackupStatus.Failed); expect(result.errorStage).toBe(BackupStage.Dump); @@ -410,7 +405,7 @@ describe('RunBackupUseCase (integration flow)', () => { expect(mockNotifier.notifyFailure).toHaveBeenCalled(); // Verify lock was still released - expect(backupLock.isLocked('vinsware')).toBe(false); + expect(backupLock.isLocked('vinelab')).toBe(false); }); // ── Audit records stages ────────────────────────────────────────── @@ -418,7 +413,7 @@ describe('RunBackupUseCase (integration flow)', () => { it('should track progress through audit log stages', async () => { const trackSpy = jest.spyOn(auditLog, 'trackProgress'); - await orchestrator.execute(new RunBackupCommand({ projectName: 'vinsware' })); + await orchestrator.execute(new RunBackupCommand({ projectName: 'vinelab' })); expect(trackSpy).toHaveBeenCalledWith(expect.any(String), BackupStage.NotifyStarted); expect(trackSpy).toHaveBeenCalledWith(expect.any(String), BackupStage.Dump); @@ -430,13 +425,13 @@ describe('RunBackupUseCase (integration flow)', () => { // ── Sync receives correct paths and tags ────────────────────────── it('should pass dump file path and tags to storage sync', async () => { - await orchestrator.execute(new RunBackupCommand({ projectName: 'vinsware' })); + await orchestrator.execute(new RunBackupCommand({ projectName: 'vinelab' })); expect(mockStorage.sync).toHaveBeenCalledWith( - ['/data/backups/vinsware/dump.sql.gz'], + ['/data/backups/vinelab/dump.sql.gz'], expect.objectContaining({ tags: expect.arrayContaining([ - 'project:vinsware', + 'project:vinelab', 'db:postgres', ]), snapshotMode: 'combined', diff --git a/test/jest-e2e.config.ts b/test/jest-e2e.config.ts index b523345..bcedf92 100644 --- a/test/jest-e2e.config.ts +++ b/test/jest-e2e.config.ts @@ -14,6 +14,7 @@ const config: Config = { '^@application/(.*)$': '/src/application/$1', '^@infrastructure/(.*)$': '/src/infrastructure/$1', '^@shared/(.*)$': '/src/shared/$1', + '^@test/(.*)$': '/test/$1', }, }; diff --git a/test/support/health-check-result.builder.ts b/test/support/health-check-result.builder.ts new file mode 100644 index 0000000..1d6bdfe --- /dev/null +++ b/test/support/health-check-result.builder.ts @@ -0,0 +1,30 @@ +import { + HealthCheckResult, + HealthCheckResultParams, + StorageHealthCheck, +} from '@domain/audit/domain/health-check-result.model'; + +export function buildStorageHealthCheck( + overrides: Partial = {}, +): StorageHealthCheck { + return { + project: 'vinelab', + backendType: 'sftp', + reachable: true, + error: null, + ...overrides, + }; +} + +export function buildHealthCheckResult( + overrides: Partial = {}, +): HealthCheckResult { + return new HealthCheckResult({ + auditDbConnected: true, + diskSpaceAvailable: true, + diskFreeGb: 50, + storageChecks: [buildStorageHealthCheck()], + uptime: 3600, + ...overrides, + }); +} diff --git a/test/support/project-config.builder.ts b/test/support/project-config.builder.ts new file mode 100644 index 0000000..00b9b4d --- /dev/null +++ b/test/support/project-config.builder.ts @@ -0,0 +1,43 @@ +import { ProjectConfig, ProjectConfigParams } from '@domain/config/domain/project-config.model'; +import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; + +export function buildProjectConfigParams( + overrides: Partial = {}, +): ProjectConfigParams { + return { + name: 'test-project', + enabled: true, + cron: '0 2 * * *', + timeoutMinutes: null, + dockerNetwork: null, + database: { + type: 'postgres', + host: 'localhost', + port: 5432, + name: 'testdb', + user: 'admin', + password: 'secret', + dumpTimeoutMinutes: null, + }, + compression: { enabled: true }, + assets: { paths: [] }, + storage: { + type: 'sftp', + repository: '/repo/test', + password: 'restic-pass', + snapshotMode: 'combined', + config: {}, + }, + retention: new RetentionPolicy(7, 14, 4), + encryption: null, + hooks: null, + verification: { enabled: false }, + notification: null, + monitor: null, + ...overrides, + }; +} + +export function buildProjectConfig(overrides: Partial = {}): ProjectConfig { + return new ProjectConfig(buildProjectConfigParams(overrides)); +} diff --git a/test/support/remote-storage.mock.ts b/test/support/remote-storage.mock.ts new file mode 100644 index 0000000..843d910 --- /dev/null +++ b/test/support/remote-storage.mock.ts @@ -0,0 +1,18 @@ +import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; + +export function createMockRemoteStorage( + overrides: Partial> = {}, +): jest.Mocked { + return { + sync: jest.fn(), + prune: jest.fn(), + listSnapshots: jest.fn(), + restore: jest.fn(), + exec: jest.fn(), + getCacheInfo: jest.fn(), + clearCache: jest.fn(), + unlock: jest.fn(), + checkConnectivity: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} diff --git a/test/unit/application/audit/get-backup-status.use-case.spec.ts b/test/unit/application/audit/get-backup-status.use-case.spec.ts index 0487dd6..8c26d46 100644 --- a/test/unit/application/audit/get-backup-status.use-case.spec.ts +++ b/test/unit/application/audit/get-backup-status.use-case.spec.ts @@ -7,6 +7,7 @@ import { BackupStatus } from '@domain/backup/domain/value-objects/backup-status. import { BackupStage } from '@domain/backup/domain/value-objects/backup-stage.enum'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; describe('GetBackupStatusUseCase', () => { let useCase: GetBackupStatusUseCase; @@ -36,22 +37,7 @@ describe('GetBackupStatusUseCase', () => { }); const createProjectConfig = (name: string): ProjectConfig => - new ProjectConfig({ - name, - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { type: 'postgres', host: 'localhost', port: 5432, name: 'db', user: 'u', password: 'p', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { repositoryPath: '/repo', password: 'secret', snapshotMode: 'combined' }, - retention: new RetentionPolicy(7, 7, 4, 6), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, - }); + buildProjectConfig({ name, retention: new RetentionPolicy(7, 7, 4, 6) }); beforeEach(() => { mockAuditLog = { @@ -75,20 +61,20 @@ describe('GetBackupStatusUseCase', () => { }); it('delegates to findByProject when projectName is given', async () => { - const results = [createResult('vinsware', 'run-1')]; + const results = [createResult('vinelab', 'run-1')]; mockAuditLog.findByProject.mockResolvedValue(results); - const status = await useCase.execute(new GetBackupStatusQuery({ projectName: 'vinsware', limit: 5 })); + const status = await useCase.execute(new GetBackupStatusQuery({ projectName: 'vinelab', limit: 5 })); - expect(mockAuditLog.findByProject).toHaveBeenCalledWith('vinsware', 5); + expect(mockAuditLog.findByProject).toHaveBeenCalledWith('vinelab', 5); expect(status).toEqual(results); }); it('aggregates results from all projects when no projectName', async () => { - const projects = [createProjectConfig('vinsware'), createProjectConfig('webapp')]; + const projects = [createProjectConfig('vinelab'), createProjectConfig('webapp')]; mockConfigLoader.loadAll.mockReturnValue(projects); mockAuditLog.findByProject - .mockResolvedValueOnce([createResult('vinsware', 'run-1')]) + .mockResolvedValueOnce([createResult('vinelab', 'run-1')]) .mockResolvedValueOnce([createResult('webapp', 'run-2')]); const status = await useCase.execute(new GetBackupStatusQuery({ limit: 10 })); @@ -96,7 +82,7 @@ describe('GetBackupStatusUseCase', () => { expect(mockConfigLoader.loadAll).toHaveBeenCalled(); expect(mockAuditLog.findByProject).toHaveBeenCalledTimes(2); expect(status).toHaveLength(2); - expect(status[0].projectName).toBe('vinsware'); + expect(status[0].projectName).toBe('vinelab'); expect(status[1].projectName).toBe('webapp'); }); }); diff --git a/test/unit/application/audit/get-failed-logs.use-case.spec.ts b/test/unit/application/audit/get-failed-logs.use-case.spec.ts index e7a92b9..9651fe2 100644 --- a/test/unit/application/audit/get-failed-logs.use-case.spec.ts +++ b/test/unit/application/audit/get-failed-logs.use-case.spec.ts @@ -46,12 +46,12 @@ describe('GetFailedLogsUseCase', () => { }); it('delegates to findFailed', async () => { - const failedResults = [createResult('vinsware', 'run-fail')]; + const failedResults = [createResult('vinelab', 'run-fail')]; mockAuditLog.findFailed.mockResolvedValue(failedResults); - const result = await useCase.execute(new GetFailedLogsQuery({ projectName: 'vinsware', limit: 3 })); + const result = await useCase.execute(new GetFailedLogsQuery({ projectName: 'vinelab', limit: 3 })); - expect(mockAuditLog.findFailed).toHaveBeenCalledWith('vinsware', 3); + expect(mockAuditLog.findFailed).toHaveBeenCalledWith('vinelab', 3); expect(result).toEqual(failedResults); }); }); diff --git a/test/unit/application/audit/startup-recovery.service.spec.ts b/test/unit/application/audit/startup-recovery.service.spec.ts index 5ab87ab..59e60ab 100644 --- a/test/unit/application/audit/startup-recovery.service.spec.ts +++ b/test/unit/application/audit/startup-recovery.service.spec.ts @@ -5,6 +5,7 @@ import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader import { BackupLockPort } from '@domain/backup/application/ports/backup-lock.port'; import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remote-storage-factory.port'; import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; +import { createMockRemoteStorage } from '@test/support/remote-storage.mock'; import { GpgKeyManagerPort } from '@domain/backup/application/ports/gpg-key-manager.port'; import { ClockPort } from '@common/clock/clock.port'; import { FileSystemPort } from '@common/filesystem/filesystem.port'; @@ -13,6 +14,7 @@ import { BackupStatus } from '@domain/backup/domain/value-objects/backup-status. import { BackupStage } from '@domain/backup/domain/value-objects/backup-stage.enum'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; import { ConfigService } from '@nestjs/config'; describe('RecoverStartupUseCase', () => { @@ -31,22 +33,7 @@ describe('RecoverStartupUseCase', () => { const fixedNow = new Date('2026-03-18T10:00:00Z'); const createProjectConfig = (name: string, enabled = true): ProjectConfig => - new ProjectConfig({ - name, - enabled, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { type: 'postgres', host: 'localhost', port: 5432, name: 'db', user: 'u', password: 'p', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { repositoryPath: '/repo', password: 'secret', snapshotMode: 'combined' }, - retention: new RetentionPolicy(7, 7, 4, 6), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, - }); + buildProjectConfig({ name, enabled, retention: new RetentionPolicy(7, 7, 4, 6) }); const createOrphanedResult = (runId: string, projectName: string): BackupResult => new BackupResult({ @@ -71,16 +58,7 @@ describe('RecoverStartupUseCase', () => { }); beforeEach(() => { - mockStorage = { - sync: jest.fn(), - prune: jest.fn(), - listSnapshots: jest.fn(), - restore: jest.fn(), - exec: jest.fn(), - getCacheInfo: jest.fn(), - clearCache: jest.fn(), - unlock: jest.fn(), - }; + mockStorage = createMockRemoteStorage(); mockAuditLog = { startRun: jest.fn(), @@ -154,7 +132,7 @@ describe('RecoverStartupUseCase', () => { }); it('marks orphaned runs as failed', async () => { - const orphan = createOrphanedResult('run-orphan-1', 'vinsware'); + const orphan = createOrphanedResult('run-orphan-1', 'vinelab'); mockAuditLog.findOrphaned.mockResolvedValue([orphan]); await service.onModuleInit(); @@ -171,19 +149,19 @@ describe('RecoverStartupUseCase', () => { }); it('cleans orphaned dump files only for projects with orphaned runs', async () => { - const orphan = createOrphanedResult('run-orphan-1', 'vinsware'); + const orphan = createOrphanedResult('run-orphan-1', 'vinelab'); mockAuditLog.findOrphaned.mockResolvedValue([orphan]); - const projects = [createProjectConfig('vinsware')]; + const projects = [createProjectConfig('vinelab')]; mockConfigLoader.loadAll.mockReturnValue(projects); mockFilesystem.exists.mockReturnValue(true); - mockFilesystem.listDirectory.mockReturnValue(['vinsware.sql.gz', 'vinsware.sql.gz.gpg', '.lock', 'notes.txt']); + mockFilesystem.listDirectory.mockReturnValue(['vinelab.sql.gz', 'vinelab.sql.gz.gpg', '.lock', 'notes.txt']); await service.onModuleInit(); - expect(mockFilesystem.removeFile).toHaveBeenCalledWith('/data/backups/vinsware/vinsware.sql.gz'); - expect(mockFilesystem.removeFile).toHaveBeenCalledWith('/data/backups/vinsware/vinsware.sql.gz.gpg'); - expect(mockFilesystem.removeFile).not.toHaveBeenCalledWith('/data/backups/vinsware/.lock'); - expect(mockFilesystem.removeFile).not.toHaveBeenCalledWith('/data/backups/vinsware/notes.txt'); + expect(mockFilesystem.removeFile).toHaveBeenCalledWith('/data/backups/vinelab/vinelab.sql.gz'); + expect(mockFilesystem.removeFile).toHaveBeenCalledWith('/data/backups/vinelab/vinelab.sql.gz.gpg'); + expect(mockFilesystem.removeFile).not.toHaveBeenCalledWith('/data/backups/vinelab/.lock'); + expect(mockFilesystem.removeFile).not.toHaveBeenCalledWith('/data/backups/vinelab/notes.txt'); }); it('skips dump cleanup when project has no orphaned runs', async () => { @@ -198,24 +176,24 @@ describe('RecoverStartupUseCase', () => { }); it('releases stale locks only for projects with orphaned runs', async () => { - const orphan1 = createOrphanedResult('run-1', 'vinsware'); + const orphan1 = createOrphanedResult('run-1', 'vinelab'); const orphan2 = createOrphanedResult('run-2', 'webapp'); mockAuditLog.findOrphaned.mockResolvedValue([orphan1, orphan2]); - const projects = [createProjectConfig('vinsware'), createProjectConfig('webapp'), createProjectConfig('untouched')]; + const projects = [createProjectConfig('vinelab'), createProjectConfig('webapp'), createProjectConfig('untouched')]; mockConfigLoader.loadAll.mockReturnValue(projects); mockBackupLock.isLocked.mockReturnValue(true); mockBackupLock.release.mockResolvedValue(undefined); await service.onModuleInit(); - expect(mockBackupLock.release).toHaveBeenCalledWith('vinsware'); + expect(mockBackupLock.release).toHaveBeenCalledWith('vinelab'); expect(mockBackupLock.release).toHaveBeenCalledWith('webapp'); expect(mockBackupLock.release).not.toHaveBeenCalledWith('untouched'); }); it('unlocks restic repos for enabled projects (non-fatal on error)', async () => { const projects = [ - createProjectConfig('vinsware', true), + createProjectConfig('vinelab', true), createProjectConfig('webapp', true), createProjectConfig('disabled-project', false), ]; @@ -236,7 +214,7 @@ describe('RecoverStartupUseCase', () => { type: 'audit', payload: new BackupResult({ runId: 'run-fb-1', - projectName: 'vinsware', + projectName: 'vinelab', status: BackupStatus.Success, currentStage: BackupStage.NotifyResult, startedAt: new Date('2026-03-18T02:00:00Z'), @@ -260,7 +238,7 @@ describe('RecoverStartupUseCase', () => { const notificationEntry: FallbackEntry = { id: 'fb-2', type: 'notification', - payload: { project: 'vinsware', message: 'Backup succeeded' }, + payload: { project: 'vinelab', message: 'Backup succeeded' }, timestamp: '2026-03-18T02:05:01Z', }; @@ -295,9 +273,9 @@ describe('RecoverStartupUseCase', () => { }); it('handles dump cleanup failure for individual files gracefully', async () => { - const orphan = createOrphanedResult('run-1', 'vinsware'); + const orphan = createOrphanedResult('run-1', 'vinelab'); mockAuditLog.findOrphaned.mockResolvedValue([orphan]); - const projects = [createProjectConfig('vinsware')]; + const projects = [createProjectConfig('vinelab')]; mockConfigLoader.loadAll.mockReturnValue(projects); mockFilesystem.exists.mockReturnValue(true); mockFilesystem.listDirectory.mockReturnValue(['dump.sql.gz']); diff --git a/test/unit/application/backup/backup-orchestrator.service.spec.ts b/test/unit/application/backup/backup-orchestrator.service.spec.ts index 9696787..11f0bcc 100644 --- a/test/unit/application/backup/backup-orchestrator.service.spec.ts +++ b/test/unit/application/backup/backup-orchestrator.service.spec.ts @@ -14,6 +14,7 @@ import { GpgKeyManagerPort } from '@domain/backup/application/ports/gpg-key-mana import { HookExecutorPort } from '@domain/backup/application/ports/hook-executor.port'; import { LocalCleanupPort } from '@domain/backup/application/ports/local-cleanup.port'; import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; +import { createMockRemoteStorage } from '@test/support/remote-storage.mock'; import { AuditLogPort } from '@domain/audit/application/ports/audit-log.port'; import { FallbackWriterPort } from '@domain/audit/application/ports/fallback-writer.port'; import { NotifierPort } from '@domain/notification/application/ports/notifier.port'; @@ -23,6 +24,7 @@ import { ClockPort } from '@common/clock/clock.port'; import { FileSystemPort } from '@common/filesystem/filesystem.port'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig as buildBaseProjectConfig } from '@test/support/project-config.builder'; import { BackupStage } from '@domain/backup/domain/value-objects/backup-stage.enum'; import { BackupStatus } from '@domain/backup/domain/value-objects/backup-status.enum'; import { CleanupResult } from '@domain/backup/domain/value-objects/cleanup-result.model'; @@ -67,16 +69,7 @@ function createMockDumper(): jest.Mocked { } function createMockStorage(): jest.Mocked { - return { - sync: jest.fn(), - prune: jest.fn(), - listSnapshots: jest.fn(), - restore: jest.fn(), - exec: jest.fn(), - getCacheInfo: jest.fn(), - clearCache: jest.fn(), - unlock: jest.fn(), - }; + return createMockRemoteStorage(); } function createMockNotifier(): jest.Mocked { @@ -172,33 +165,9 @@ function createMockGpgKeyManager(): jest.Mocked { } function buildProjectConfig(overrides: Partial[0]> = {}): ProjectConfig { - return new ProjectConfig({ - name: 'test-project', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { - type: 'postgres', - host: 'localhost', - port: 5432, - name: 'testdb', - user: 'admin', - password: 'secret', - dumpTimeoutMinutes: null, - }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { - repositoryPath: '/repo/test', - password: 'restic-pass', - snapshotMode: 'combined', - }, + return buildBaseProjectConfig({ retention: new RetentionPolicy(7, 7, 4, 3), - encryption: null, - hooks: null, - verification: { enabled: false }, notification: { type: 'slack', config: {} }, - monitor: null, ...overrides, }); } diff --git a/test/unit/application/backup/clear-cache.use-case.spec.ts b/test/unit/application/backup/clear-cache.use-case.spec.ts index 82a62ff..b74990a 100644 --- a/test/unit/application/backup/clear-cache.use-case.spec.ts +++ b/test/unit/application/backup/clear-cache.use-case.spec.ts @@ -3,8 +3,10 @@ import { ClearCacheCommand } from '@domain/backup/application/use-cases/clear-ca import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remote-storage-factory.port'; import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; +import { createMockRemoteStorage } from '@test/support/remote-storage.mock'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; describe('ClearCacheUseCase', () => { let useCase: ClearCacheUseCase; @@ -13,34 +15,10 @@ describe('ClearCacheUseCase', () => { let mockStorage: jest.Mocked; const createProjectConfig = (name: string, enabled = true): ProjectConfig => - new ProjectConfig({ - name, - enabled, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { type: 'postgres', host: 'localhost', port: 5432, name: 'db', user: 'u', password: 'p', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { repositoryPath: '/repo', password: 'secret', snapshotMode: 'combined' }, - retention: new RetentionPolicy(7, 7, 4, 6), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, - }); + buildProjectConfig({ name, enabled, retention: new RetentionPolicy(7, 7, 4, 6) }); beforeEach(() => { - mockStorage = { - sync: jest.fn(), - prune: jest.fn(), - listSnapshots: jest.fn(), - restore: jest.fn(), - exec: jest.fn(), - getCacheInfo: jest.fn(), - clearCache: jest.fn().mockResolvedValue(undefined), - unlock: jest.fn(), - }; + mockStorage = createMockRemoteStorage({ clearCache: jest.fn().mockResolvedValue(undefined) }); mockStorageFactory = { create: jest.fn().mockReturnValue(mockStorage), diff --git a/test/unit/application/backup/get-cache-info.use-case.spec.ts b/test/unit/application/backup/get-cache-info.use-case.spec.ts index 6521449..05a05eb 100644 --- a/test/unit/application/backup/get-cache-info.use-case.spec.ts +++ b/test/unit/application/backup/get-cache-info.use-case.spec.ts @@ -3,9 +3,11 @@ import { GetCacheInfoQuery } from '@domain/backup/application/use-cases/get-cach import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remote-storage-factory.port'; import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; +import { createMockRemoteStorage } from '@test/support/remote-storage.mock'; import { CacheInfo } from '@domain/backup/domain/value-objects/cache-info.model'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; describe('GetCacheInfoUseCase', () => { let useCase: GetCacheInfoUseCase; @@ -14,34 +16,10 @@ describe('GetCacheInfoUseCase', () => { let mockStorage: jest.Mocked; const createProjectConfig = (name: string, enabled = true): ProjectConfig => - new ProjectConfig({ - name, - enabled, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { type: 'postgres', host: 'localhost', port: 5432, name: 'db', user: 'u', password: 'p', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { repositoryPath: '/repo', password: 'secret', snapshotMode: 'combined' }, - retention: new RetentionPolicy(7, 7, 4, 6), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, - }); + buildProjectConfig({ name, enabled, retention: new RetentionPolicy(7, 7, 4, 6) }); beforeEach(() => { - mockStorage = { - sync: jest.fn(), - prune: jest.fn(), - listSnapshots: jest.fn(), - restore: jest.fn(), - exec: jest.fn(), - getCacheInfo: jest.fn(), - clearCache: jest.fn(), - unlock: jest.fn(), - }; + mockStorage = createMockRemoteStorage(); mockStorageFactory = { create: jest.fn().mockReturnValue(mockStorage), diff --git a/test/unit/application/backup/get-restore-guide.use-case.spec.ts b/test/unit/application/backup/get-restore-guide.use-case.spec.ts index 24c04d5..722703f 100644 --- a/test/unit/application/backup/get-restore-guide.use-case.spec.ts +++ b/test/unit/application/backup/get-restore-guide.use-case.spec.ts @@ -3,23 +3,12 @@ import { GetRestoreGuideQuery } from '@domain/backup/application/use-cases/get-r import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; import { ProjectConfig, ProjectConfigParams } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; function buildConfig(overrides: Partial = {}): ProjectConfig { - return new ProjectConfig({ - name: 'test-project', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, + return buildProjectConfig({ database: { type: 'postgres', host: 'db.example.com', port: 5432, name: 'mydb', user: 'admin', password: 'secret', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { repositoryPath: '/repo', password: 'pass', snapshotMode: 'combined' }, retention: new RetentionPolicy(7, 7, 4, 3), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, ...overrides, }); } diff --git a/test/unit/application/backup/prune-backup.use-case.spec.ts b/test/unit/application/backup/prune-backup.use-case.spec.ts index 1e0eda9..418df4f 100644 --- a/test/unit/application/backup/prune-backup.use-case.spec.ts +++ b/test/unit/application/backup/prune-backup.use-case.spec.ts @@ -4,9 +4,11 @@ import { PruneBackupCommand } from '@domain/backup/application/use-cases/prune-b import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remote-storage-factory.port'; import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; +import { createMockRemoteStorage } from '@test/support/remote-storage.mock'; import { PruneResult } from '@domain/backup/domain/value-objects/prune-result.model'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; describe('PruneBackupUseCase', () => { let useCase: PruneBackupUseCase; @@ -16,34 +18,10 @@ describe('PruneBackupUseCase', () => { let loggerErrorSpy: jest.SpyInstance; const createProjectConfig = (name: string, enabled = true): ProjectConfig => - new ProjectConfig({ - name, - enabled, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { type: 'postgres', host: 'localhost', port: 5432, name: 'db', user: 'u', password: 'p', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { repositoryPath: '/repo', password: 'secret', snapshotMode: 'combined' }, - retention: new RetentionPolicy(7, 7, 4, 6), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, - }); + buildProjectConfig({ name, enabled, retention: new RetentionPolicy(7, 7, 4, 6) }); beforeEach(() => { - mockStorage = { - sync: jest.fn(), - prune: jest.fn(), - listSnapshots: jest.fn(), - restore: jest.fn(), - exec: jest.fn(), - getCacheInfo: jest.fn(), - clearCache: jest.fn(), - unlock: jest.fn(), - }; + mockStorage = createMockRemoteStorage(); mockStorageFactory = { create: jest.fn().mockReturnValue(mockStorage), diff --git a/test/unit/application/backup/registries/dumper.registry.spec.ts b/test/unit/application/backup/registries/dumper.registry.spec.ts index 4251b4d..f49d1d0 100644 --- a/test/unit/application/backup/registries/dumper.registry.spec.ts +++ b/test/unit/application/backup/registries/dumper.registry.spec.ts @@ -2,24 +2,10 @@ import { DatabaseDumperPort } from '@domain/backup/application/ports/database-du import { DumperRegistry, DumperFactory } from '@domain/backup/application/registries/dumper.registry'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; function buildConfig(): ProjectConfig { - return new ProjectConfig({ - name: 'test', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { type: 'postgres', host: 'localhost', port: 5432, name: 'testdb', user: 'u', password: 'p', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { repositoryPath: '/repo', password: 'pass', snapshotMode: 'combined' }, - retention: new RetentionPolicy(7, 7, 4, 3), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, - }); + return buildProjectConfig({ name: 'test', retention: new RetentionPolicy(7, 7, 4, 3) }); } function createMockDumper(): DatabaseDumperPort { diff --git a/test/unit/application/backup/restore-backup.use-case.spec.ts b/test/unit/application/backup/restore-backup.use-case.spec.ts index 2b08ba3..534c042 100644 --- a/test/unit/application/backup/restore-backup.use-case.spec.ts +++ b/test/unit/application/backup/restore-backup.use-case.spec.ts @@ -6,8 +6,10 @@ import { RestoreBackupCommand } from '@domain/backup/application/use-cases/resto import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remote-storage-factory.port'; import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; +import { createMockRemoteStorage } from '@test/support/remote-storage.mock'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig as buildBaseProjectConfig } from '@test/support/project-config.builder'; import { safeExecFile } from '@common/helpers/child-process.util'; @@ -39,48 +41,16 @@ function createMockConfigLoader(): jest.Mocked { } function createMockStorage(): jest.Mocked { - return { - sync: jest.fn(), - prune: jest.fn(), - listSnapshots: jest.fn(), - restore: jest.fn().mockResolvedValue(undefined), - exec: jest.fn(), - getCacheInfo: jest.fn(), - clearCache: jest.fn(), - unlock: jest.fn(), - }; + return createMockRemoteStorage({ restore: jest.fn().mockResolvedValue(undefined) }); } function buildProjectConfig( overrides: Partial[0]> = {}, ): ProjectConfig { - return new ProjectConfig({ - name: 'test-project', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { - type: 'postgres', - host: 'localhost', - port: 5432, - name: 'testdb', - user: 'admin', - password: 'secret', - dumpTimeoutMinutes: null, - }, - compression: { enabled: true }, + return buildBaseProjectConfig({ assets: { paths: ['/data/uploads', '/data/static'] }, - restic: { - repositoryPath: '/repo/test', - password: 'restic-pass', - snapshotMode: 'combined', - }, retention: new RetentionPolicy(7, 7, 4, 3), - encryption: null, - hooks: null, - verification: { enabled: false }, notification: { type: 'slack', config: {} }, - monitor: null, ...overrides, }); } diff --git a/test/unit/application/health/check-health.use-case.spec.ts b/test/unit/application/health/check-health.use-case.spec.ts index ed583b6..5db05a2 100644 --- a/test/unit/application/health/check-health.use-case.spec.ts +++ b/test/unit/application/health/check-health.use-case.spec.ts @@ -4,9 +4,19 @@ import { CheckHealthUseCase } from '@domain/health/application/use-cases/check-h import { AuditLogPort } from '@domain/audit/application/ports/audit-log.port'; import { SystemHealthPort } from '@domain/health/application/ports/system-health.port'; import { HeartbeatMonitorPort } from '@domain/backup/application/ports/heartbeat-monitor.port'; +import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remote-storage-factory.port'; +import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; +import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; +import { StorageBackendType } from '@domain/config/domain/storage-config.model'; +import { ClockPort } from '@common/clock/clock.port'; +import { buildProjectConfig } from '@test/support/project-config.builder'; +import { createMockRemoteStorage } from '@test/support/remote-storage.mock'; import { AUDIT_LOG_PORT, + CLOCK_PORT, + CONFIG_LOADER_PORT, HEARTBEAT_MONITOR_PORT, + REMOTE_STORAGE_FACTORY, SYSTEM_HEALTH_PORT, } from '@common/di/injection-tokens'; @@ -15,23 +25,50 @@ describe('CheckHealthUseCase', () => { let mockAuditLog: jest.Mocked; let mockSystemHealth: jest.Mocked; let mockHeartbeatMonitor: jest.Mocked; + let mockConfigLoader: jest.Mocked; + let mockStorageFactory: jest.Mocked; + let mockStorage: jest.Mocked; let configValues: Record; + let currentTime: Date; + + function setProjects(projects: Array<{ name: string; type: StorageBackendType; enabled?: boolean }>): void { + const configs = projects.map((project) => + buildProjectConfig({ + name: project.name, + enabled: project.enabled ?? true, + storage: { + type: project.type, + repository: 'repo', + password: 'pass', + snapshotMode: 'combined', + config: {}, + }, + }), + ); + + mockConfigLoader.loadAll.mockReturnValue(configs); + mockConfigLoader.getProject.mockImplementation((name: string) => { + const found = configs.find((config) => config.name === name); + if (!found) throw new Error(`Project "${name}" not found`); + return found; + }); + } beforeEach(async () => { + currentTime = new Date('2026-07-15T10:00:00Z'); + mockAuditLog = { startRun: jest.fn(), trackProgress: jest.fn(), finishRun: jest.fn(), findByProject: jest.fn(), findFailed: jest.fn(), - findSince: jest.fn(), + findSince: jest.fn().mockResolvedValue([]), findOrphaned: jest.fn(), }; mockSystemHealth = { checkDiskSpace: jest.fn().mockResolvedValue({ available: true, freeGb: 20 }), - checkSshConnectivity: jest.fn().mockResolvedValue(true), - checkSshAuthentication: jest.fn().mockResolvedValue(true), }; mockHeartbeatMonitor = { @@ -39,29 +76,43 @@ describe('CheckHealthUseCase', () => { checkConnectivity: jest.fn().mockResolvedValue(true), }; - configValues = { - HEALTH_DISK_MIN_FREE_GB: 5, - HETZNER_SSH_HOST: 'storage.example.com', - HETZNER_SSH_PORT: 23, - HETZNER_SSH_USER: 'u123', - HETZNER_SSH_KEY_PATH: '/home/node/.ssh/id_ed25519', + mockStorage = createMockRemoteStorage(); + + mockStorageFactory = { create: jest.fn().mockReturnValue(mockStorage) }; + + mockConfigLoader = { + loadAll: jest.fn().mockReturnValue([]), + getProject: jest.fn(), + validate: jest.fn(), + reload: jest.fn(), }; + configValues = { HEALTH_DISK_MIN_FREE_GB: 5 }; + const mockConfigService = { get: jest.fn((key: string, defaultValue?: unknown) => configValues[key] ?? defaultValue), } as unknown as ConfigService; + const mockClock: jest.Mocked = { + now: jest.fn(() => currentTime), + timestamp: jest.fn().mockReturnValue('20260715-100000'), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ CheckHealthUseCase, { provide: AUDIT_LOG_PORT, useValue: mockAuditLog }, { provide: SYSTEM_HEALTH_PORT, useValue: mockSystemHealth }, { provide: HEARTBEAT_MONITOR_PORT, useValue: mockHeartbeatMonitor }, + { provide: CONFIG_LOADER_PORT, useValue: mockConfigLoader }, + { provide: REMOTE_STORAGE_FACTORY, useValue: mockStorageFactory }, + { provide: CLOCK_PORT, useValue: mockClock }, { provide: ConfigService, useValue: mockConfigService }, ], }).compile(); service = module.get(CheckHealthUseCase); + setProjects([{ name: 'vinelab', type: 'sftp' }]); }); afterEach(() => { @@ -69,24 +120,20 @@ describe('CheckHealthUseCase', () => { }); it('returns healthy result when all checks pass', async () => { - mockAuditLog.findSince.mockResolvedValue([]); - const result = await service.execute(); expect(result.auditDbConnected).toBe(true); expect(result.diskSpaceAvailable).toBe(true); expect(result.diskFreeGb).toBe(20); - expect(result.sshConnected).toBe(true); - expect(result.sshAuthenticated).toBe(true); + expect(result.storageChecks).toEqual([ + { project: 'vinelab', backendType: 'sftp', reachable: true, error: null }, + ]); expect(result.isHealthy()).toBe(true); expect(mockHeartbeatMonitor.checkConnectivity).not.toHaveBeenCalled(); - expect(result.uptimeKumaConfigured).toBe(false); - expect(result.uptimeKumaConnected).toBe(false); }); it('calls checkConnectivity and sets uptimeKumaConfigured when UPTIME_KUMA_BASE_URL is set', async () => { configValues['UPTIME_KUMA_BASE_URL'] = 'https://kuma.example.com'; - mockAuditLog.findSince.mockResolvedValue([]); const result = await service.execute(); @@ -96,8 +143,6 @@ describe('CheckHealthUseCase', () => { }); it('does not call checkConnectivity when UPTIME_KUMA_BASE_URL is not set', async () => { - mockAuditLog.findSince.mockResolvedValue([]); - const result = await service.execute(); expect(mockHeartbeatMonitor.checkConnectivity).not.toHaveBeenCalled(); @@ -107,12 +152,10 @@ describe('CheckHealthUseCase', () => { it('isHealthy() ignores Uptime Kuma when Kuma is unreachable but core checks pass', async () => { configValues['UPTIME_KUMA_BASE_URL'] = 'https://kuma.example.com'; - mockAuditLog.findSince.mockResolvedValue([]); mockHeartbeatMonitor.checkConnectivity.mockResolvedValue(false); const result = await service.execute(); - expect(result.uptimeKumaConfigured).toBe(true); expect(result.uptimeKumaConnected).toBe(false); expect(result.isHealthy()).toBe(true); }); @@ -128,7 +171,6 @@ describe('CheckHealthUseCase', () => { it('disk space check fails when below threshold', async () => { configValues['HEALTH_DISK_MIN_FREE_GB'] = 50; - mockAuditLog.findSince.mockResolvedValue([]); mockSystemHealth.checkDiskSpace.mockResolvedValue({ available: false, freeGb: 10 }); const result = await service.execute(); @@ -138,53 +180,143 @@ describe('CheckHealthUseCase', () => { expect(result.isHealthy()).toBe(false); }); - it('disk space check fails gracefully on adapter error', async () => { - mockAuditLog.findSince.mockResolvedValue([]); - mockSystemHealth.checkDiskSpace.mockResolvedValue({ available: false, freeGb: 0 }); - - const result = await service.execute(); - - expect(result.diskSpaceAvailable).toBe(false); - expect(result.diskFreeGb).toBe(0); - }); - it('disk check passes when free space equals threshold', async () => { configValues['HEALTH_DISK_MIN_FREE_GB'] = 10; - mockAuditLog.findSince.mockResolvedValue([]); mockSystemHealth.checkDiskSpace.mockResolvedValue({ available: true, freeGb: 10 }); const result = await service.execute(); expect(result.diskSpaceAvailable).toBe(true); - expect(result.diskFreeGb).toBe(10); expect(mockSystemHealth.checkDiskSpace).toHaveBeenCalledWith('/', 10); }); - it('SSH check skipped when HETZNER_SSH_HOST not configured', async () => { - configValues['HETZNER_SSH_HOST'] = ''; - mockAuditLog.findSince.mockResolvedValue([]); + describe('storage backend checks', () => { + it('probes every enabled project through its own storage port', async () => { + setProjects([ + { name: 'a', type: 's3' }, + { name: 'b', type: 'rclone' }, + ]); + + const result = await service.execute(); + + expect(mockStorage.checkConnectivity).toHaveBeenCalledTimes(2); + expect(result.storageChecks).toEqual([ + { project: 'a', backendType: 's3', reachable: true, error: null }, + { project: 'b', backendType: 'rclone', reachable: true, error: null }, + ]); + }); + + it('skips disabled projects', async () => { + setProjects([ + { name: 'a', type: 's3' }, + { name: 'b', type: 's3', enabled: false }, + ]); + + const result = await service.execute(); + + expect(result.storageChecks).toHaveLength(1); + expect(result.storageChecks[0].project).toBe('a'); + }); + + it('reports a repo as unreachable with the underlying error, and turns the result unhealthy', async () => { + mockStorage.checkConnectivity.mockRejectedValue(new Error('Fatal: unable to open config file')); + + const result = await service.execute(); + + expect(result.storageChecks).toEqual([ + { + project: 'vinelab', + backendType: 'sftp', + reachable: false, + error: 'Fatal: unable to open config file', + }, + ]); + expect(result.isHealthy()).toBe(false); + }); + + it('isolates a failing project from a healthy one', async () => { + setProjects([ + { name: 'good', type: 's3' }, + { name: 'bad', type: 's3' }, + ]); + mockStorage.checkConnectivity + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('403 Forbidden')); + + const result = await service.execute(); + + expect(result.storageChecks[0].reachable).toBe(true); + expect(result.storageChecks[1].reachable).toBe(false); + expect(result.isHealthy()).toBe(false); + }); + + it('reports unreachable rather than throwing when the factory itself throws', async () => { + mockStorageFactory.create.mockImplementation(() => { + throw new Error('Restic password not configured'); + }); + + const result = await service.execute(); + + expect(result.storageChecks[0].reachable).toBe(false); + expect(result.storageChecks[0].error).toContain('Restic password not configured'); + }); + + it('is healthy with no storage checks when the config cannot be read', async () => { + mockConfigLoader.loadAll.mockImplementation(() => { + throw new Error('projects.yml is malformed'); + }); + + const result = await service.execute(); + + expect(result.storageChecks).toEqual([]); + expect(result.isHealthy()).toBe(true); + }); + }); - const result = await service.execute(); + describe('storage check caching', () => { + it('does not re-probe within the TTL', async () => { + await service.execute(); + await service.execute(); + await service.execute(); - expect(result.sshConnected).toBe(false); - expect(mockSystemHealth.checkSshConnectivity).not.toHaveBeenCalled(); - }); + expect(mockStorage.checkConnectivity).toHaveBeenCalledTimes(1); + }); - it('SSH connectivity check fails when adapter returns false', async () => { - mockAuditLog.findSince.mockResolvedValue([]); - mockSystemHealth.checkSshConnectivity.mockResolvedValue(false); + it('re-probes once the TTL has elapsed', async () => { + await service.execute(); - const result = await service.execute(); + currentTime = new Date(currentTime.getTime() + 301_000); + await service.execute(); - expect(result.sshConnected).toBe(false); - }); + expect(mockStorage.checkConnectivity).toHaveBeenCalledTimes(2); + }); - it('SSH auth check fails when adapter returns false', async () => { - mockAuditLog.findSince.mockResolvedValue([]); - mockSystemHealth.checkSshAuthentication.mockResolvedValue(false); + it('serves the cached result within the TTL', async () => { + const first = await service.execute(); - const result = await service.execute(); + mockStorage.checkConnectivity.mockRejectedValue(new Error('now broken')); + const second = await service.execute(); + + expect(second.storageChecks).toEqual(first.storageChecks); + expect(second.isHealthy()).toBe(true); + }); + + it('honours a custom TTL', async () => { + configValues['HEALTH_STORAGE_CHECK_TTL_SECONDS'] = 60; + + await service.execute(); + currentTime = new Date(currentTime.getTime() + 61_000); + await service.execute(); + + expect(mockStorage.checkConnectivity).toHaveBeenCalledTimes(2); + }); + + it('still checks the audit DB on every call despite the storage cache', async () => { + await service.execute(); + await service.execute(); - expect(result.sshAuthenticated).toBe(false); + expect(mockAuditLog.findSince).toHaveBeenCalledTimes(2); + expect(mockSystemHealth.checkDiskSpace).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/test/unit/application/health/check-liveness.use-case.spec.ts b/test/unit/application/health/check-liveness.use-case.spec.ts new file mode 100644 index 0000000..014f0fc --- /dev/null +++ b/test/unit/application/health/check-liveness.use-case.spec.ts @@ -0,0 +1,69 @@ +import { ConfigService } from '@nestjs/config'; +import { CheckLivenessUseCase } from '@domain/health/application/use-cases/check-liveness/check-liveness.use-case'; +import { AuditLogPort } from '@domain/audit/application/ports/audit-log.port'; +import { SystemHealthPort } from '@domain/health/application/ports/system-health.port'; + +describe('CheckLivenessUseCase', () => { + let useCase: CheckLivenessUseCase; + let mockAuditLog: jest.Mocked; + let mockSystemHealth: jest.Mocked; + let configValues: Record; + + beforeEach(() => { + mockAuditLog = { + startRun: jest.fn(), + trackProgress: jest.fn(), + finishRun: jest.fn(), + findByProject: jest.fn(), + findFailed: jest.fn(), + findSince: jest.fn().mockResolvedValue([]), + findOrphaned: jest.fn(), + }; + + mockSystemHealth = { + checkDiskSpace: jest.fn().mockResolvedValue({ available: true, freeGb: 20 }), + }; + + configValues = { HEALTH_DISK_MIN_FREE_GB: 5 }; + + const mockConfigService = { + get: jest.fn((key: string, defaultValue?: unknown) => configValues[key] ?? defaultValue), + } as unknown as ConfigService; + + useCase = new CheckLivenessUseCase(mockAuditLog, mockSystemHealth, mockConfigService); + }); + + it('is alive when the audit DB and disk are fine', async () => { + const result = await useCase.execute(); + + expect(result.auditDbConnected).toBe(true); + expect(result.diskSpaceAvailable).toBe(true); + expect(result.diskFreeGb).toBe(20); + expect(result.isAlive()).toBe(true); + }); + + it('is not alive when the audit DB is down', async () => { + mockAuditLog.findSince.mockRejectedValue(new Error('Connection refused')); + + const result = await useCase.execute(); + + expect(result.auditDbConnected).toBe(false); + expect(result.isAlive()).toBe(false); + }); + + it('is not alive when disk space is below the threshold', async () => { + mockSystemHealth.checkDiskSpace.mockResolvedValue({ available: false, freeGb: 1 }); + + const result = await useCase.execute(); + + expect(result.isAlive()).toBe(false); + }); + + it('honours the configured disk threshold', async () => { + configValues['HEALTH_DISK_MIN_FREE_GB'] = 10; + + await useCase.execute(); + + expect(mockSystemHealth.checkDiskSpace).toHaveBeenCalledWith('/', 10); + }); +}); diff --git a/test/unit/application/network/connect-network.use-case.spec.ts b/test/unit/application/network/connect-network.use-case.spec.ts index b62917d..d917e52 100644 --- a/test/unit/application/network/connect-network.use-case.spec.ts +++ b/test/unit/application/network/connect-network.use-case.spec.ts @@ -4,6 +4,7 @@ import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader import { DockerNetworkPort } from '@domain/network/application/ports/docker-network.port'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; import { ConfigService } from '@nestjs/config'; describe('ConnectNetworkUseCase', () => { @@ -13,22 +14,11 @@ describe('ConnectNetworkUseCase', () => { let mockConfigService: jest.Mocked; const createProjectConfig = (name: string, dockerNetwork: string | null = null): ProjectConfig => - new ProjectConfig({ + buildProjectConfig({ name, - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, dockerNetwork, database: { type: 'postgres', host: 'postgres', port: 5432, name: 'db', user: 'u', password: 'p', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { repositoryPath: '/repo', password: 'secret', snapshotMode: 'combined' }, retention: new RetentionPolicy(7, 7, 4, 6), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, }); beforeEach(() => { diff --git a/test/unit/application/snapshot/snapshot-management.service.spec.ts b/test/unit/application/snapshot/snapshot-management.service.spec.ts index c09a464..1a75391 100644 --- a/test/unit/application/snapshot/snapshot-management.service.spec.ts +++ b/test/unit/application/snapshot/snapshot-management.service.spec.ts @@ -3,9 +3,11 @@ import { ListSnapshotsQuery } from '@domain/backup/application/use-cases/list-sn import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remote-storage-factory.port'; import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; +import { createMockRemoteStorage } from '@test/support/remote-storage.mock'; import { SnapshotInfo } from '@domain/backup/domain/value-objects/snapshot-info.model'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; describe('ListSnapshotsUseCase', () => { let service: ListSnapshotsUseCase; @@ -18,36 +20,15 @@ describe('ListSnapshotsUseCase', () => { new SnapshotInfo(id, time, ['/data/backups/test'], 'host', ['db'], '100MB'); beforeEach(() => { - mockStorage = { - sync: jest.fn(), - prune: jest.fn(), - listSnapshots: jest.fn(), - restore: jest.fn(), - exec: jest.fn(), - getCacheInfo: jest.fn(), - clearCache: jest.fn(), - unlock: jest.fn(), - }; + mockStorage = createMockRemoteStorage(); mockStorageFactory = { create: jest.fn().mockReturnValue(mockStorage), }; - projectConfig = new ProjectConfig({ - name: 'test-project', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, + projectConfig = buildProjectConfig({ database: { type: 'postgres', host: 'localhost', port: 5432, name: 'testdb', user: 'user', password: 'pass', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { repositoryPath: '/repo', password: 'secret', snapshotMode: 'combined' }, retention: new RetentionPolicy(7, 7, 4, 6), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, }); mockConfigLoader = { diff --git a/test/unit/domain/config/models/project-config.model.spec.ts b/test/unit/domain/config/models/project-config.model.spec.ts index c35d3df..30f7939 100644 --- a/test/unit/domain/config/models/project-config.model.spec.ts +++ b/test/unit/domain/config/models/project-config.model.spec.ts @@ -1,36 +1,8 @@ import { ProjectConfig, ProjectConfigParams } from '@domain/config/domain/project-config.model'; -import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfigParams } from '@test/support/project-config.builder'; function buildParams(overrides: Partial = {}): ProjectConfigParams { - return { - name: 'test-project', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { - type: 'postgres', - host: 'localhost', - port: 5432, - name: 'testdb', - user: 'admin', - password: 'secret', - dumpTimeoutMinutes: null, - }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { - repositoryPath: '/repo/test', - password: 'restic-pass', - snapshotMode: 'combined', - }, - retention: new RetentionPolicy(7, 14, 4), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, - ...overrides, - }; + return buildProjectConfigParams(overrides); } describe('ProjectConfig', () => { diff --git a/test/unit/infrastructure/adapters/config/yaml-config-loader.adapter.spec.ts b/test/unit/infrastructure/adapters/config/yaml-config-loader.adapter.spec.ts index 947c2f9..550363f 100644 --- a/test/unit/infrastructure/adapters/config/yaml-config-loader.adapter.spec.ts +++ b/test/unit/infrastructure/adapters/config/yaml-config-loader.adapter.spec.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as yaml from 'js-yaml'; +import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { YamlConfigLoaderAdapter } from '@domain/config/infrastructure/yaml-config-loader.adapter'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; @@ -37,10 +38,18 @@ function buildMinimalYaml(overrides: Record = {}): string { return yaml.dump({ projects: [project] }); } +const SFTP_ENV: Record = { + HETZNER_SSH_HOST: 'storage.example.com', + HETZNER_SSH_USER: 'u123', + HETZNER_SSH_KEY_PATH: '/home/node/.ssh/id_ed25519', +}; + function createAdapter(envOverrides: Record = {}): YamlConfigLoaderAdapter { + const env: Record = { ...SFTP_ENV, ...envOverrides }; + const configService = { get: jest.fn((key: string, defaultValue?: string) => { - if (key in envOverrides) return envOverrides[key]; + if (key in env) return env[key]; return defaultValue; }), } as unknown as ConfigService; @@ -71,7 +80,7 @@ describe('YamlConfigLoaderAdapter', () => { expect(result[0]).toBeInstanceOf(ProjectConfig); expect(result[0].name).toBe('test-project'); expect(result[0].database?.type).toBe('postgres'); - expect(result[0].restic.repositoryPath).toBe('/backups/test'); + expect(result[0].storage.repository).toBe('/backups/test'); }); it('should resolve ${VAR_NAME} from environment', () => { @@ -171,7 +180,7 @@ describe('YamlConfigLoaderAdapter', () => { const result = adapter.loadAll(); - expect(result[0].restic.password).toBe('global-restic-pass'); + expect(result[0].storage.password).toBe('global-restic-pass'); }); it('should load files-only config (no database)', () => { @@ -235,6 +244,176 @@ describe('YamlConfigLoaderAdapter', () => { }); }); + describe('storage backends', () => { + const s3Storage = { + type: 's3', + repository: 'my-bucket/test', + password: 'restic-pass', + snapshot_mode: 'separate', + config: { + endpoint: 'https://s3.eu-central-003.backblazeb2.com', + access_key_id: 'key-id', + secret_access_key: 'secret', + }, + }; + + function buildStorageYaml(storage: unknown): string { + const project = { + name: 'test-project', + enabled: true, + cron: '0 0 * * *', + database: { type: 'postgres', host: 'localhost', port: 5432, name: 'testdb', user: 'user', password: 'secret' }, + storage, + retention: { local_days: 7, keep_daily: 7, keep_weekly: 4 }, + }; + return yaml.dump({ projects: [project] }); + } + + it('loads an s3 storage block', () => { + mockedFs.readFileSync.mockReturnValue(buildStorageYaml(s3Storage)); + + const result = createAdapter().loadAll(); + + expect(result[0].storage).toEqual({ + type: 's3', + repository: 'my-bucket/test', + password: 'restic-pass', + snapshotMode: 'separate', + config: { + endpoint: 'https://s3.eu-central-003.backblazeb2.com', + access_key_id: 'key-id', + secret_access_key: 'secret', + }, + }); + }); + + it('maps a legacy restic block onto storage with type sftp', () => { + mockedFs.readFileSync.mockReturnValue(buildMinimalYaml()); + + const result = createAdapter().loadAll(); + + expect(result[0].storage).toEqual({ + type: 'sftp', + repository: '/backups/test', + password: 'restic-pass', + snapshotMode: 'combined', + config: {}, + }); + }); + + it('accepts a legacy restic block as valid', () => { + mockedFs.readFileSync.mockReturnValue(buildMinimalYaml()); + + expect(createAdapter().validate().isValid).toBe(true); + }); + + it('warns about the deprecated block only once, however often loadAll is called', () => { + mockedFs.readFileSync.mockReturnValue(buildMinimalYaml()); + const warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const adapter = createAdapter(); + + adapter.loadAll(); + adapter.loadAll(); + adapter.loadAll(); + + const deprecationWarnings = warnSpy.mock.calls.filter((call) => + String(call[0]).includes('is deprecated'), + ); + expect(deprecationWarnings).toHaveLength(1); + warnSpy.mockRestore(); + }); + + it('warns again after an explicit reload', () => { + mockedFs.readFileSync.mockReturnValue(buildMinimalYaml()); + const warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const adapter = createAdapter(); + + adapter.loadAll(); + adapter.reload(); + + const deprecationWarnings = warnSpy.mock.calls.filter((call) => + String(call[0]).includes('is deprecated'), + ); + expect(deprecationWarnings).toHaveLength(2); + warnSpy.mockRestore(); + }); + + it('rejects a config carrying both restic and storage', () => { + const project = { + name: 'test-project', + cron: '0 0 * * *', + database: { type: 'postgres', host: 'localhost', port: 5432, name: 'testdb', user: 'user', password: 'secret' }, + restic: { repository_path: '/backups/test', password: 'p' }, + storage: s3Storage, + retention: { local_days: 7, keep_daily: 7, keep_weekly: 4 }, + }; + mockedFs.readFileSync.mockReturnValue(yaml.dump({ projects: [project] })); + + const result = createAdapter().validate(); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('both "restic" and "storage"')); + }); + + it('rejects an unknown storage type', () => { + mockedFs.readFileSync.mockReturnValue(buildStorageYaml({ ...s3Storage, type: 'dropbox' })); + + const result = createAdapter().validate(); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('invalid storage type "dropbox"')); + }); + + it('rejects an s3 block missing required credentials', () => { + mockedFs.readFileSync.mockReturnValue( + buildStorageYaml({ ...s3Storage, config: { endpoint: 'https://s3.example.com' } }), + ); + + const result = createAdapter().validate(); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('requires config.access_key_id')); + expect(result.errors).toContainEqual(expect.stringContaining('requires config.secret_access_key')); + }); + + it('rejects an invalid snapshot_mode', () => { + mockedFs.readFileSync.mockReturnValue(buildStorageYaml({ ...s3Storage, snapshot_mode: 'combinedd' })); + + const result = createAdapter().validate(); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('invalid snapshot_mode "combinedd"')); + }); + + it('does not require Hetzner SSH env for a non-sftp backend', () => { + mockedFs.readFileSync.mockReturnValue(buildStorageYaml(s3Storage)); + const adapter = createAdapter({ HETZNER_SSH_HOST: '', HETZNER_SSH_USER: '', HETZNER_SSH_KEY_PATH: '' }); + + expect(adapter.validate().isValid).toBe(true); + }); + + it('requires Hetzner SSH env for an sftp backend', () => { + mockedFs.readFileSync.mockReturnValue(buildMinimalYaml()); + const adapter = createAdapter({ HETZNER_SSH_HOST: '' }); + + const result = adapter.validate(); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('requires HETZNER_SSH_HOST in .env')); + }); + + it('requires a password from either the project or RESTIC_PASSWORD', () => { + mockedFs.readFileSync.mockReturnValue( + buildStorageYaml({ ...s3Storage, password: undefined }), + ); + + const result = createAdapter().validate(); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('requires a password')); + }); + }); + describe('validate', () => { it('should return valid for correct config', () => { mockedFs.readFileSync.mockReturnValue(buildMinimalYaml()); diff --git a/test/unit/infrastructure/adapters/dumpers/dumper-bootstrap.service.spec.ts b/test/unit/infrastructure/adapters/dumpers/dumper-bootstrap.service.spec.ts index 81f12dd..51e83cb 100644 --- a/test/unit/infrastructure/adapters/dumpers/dumper-bootstrap.service.spec.ts +++ b/test/unit/infrastructure/adapters/dumpers/dumper-bootstrap.service.spec.ts @@ -2,6 +2,7 @@ import { DumperBootstrapService } from '@domain/backup/infrastructure/adapters/d import { DumperRegistry } from '@domain/backup/application/registries/dumper.registry'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; describe('DumperBootstrapService', () => { let registry: DumperRegistry; @@ -13,21 +14,10 @@ describe('DumperBootstrapService', () => { }); function buildConfig(dbType: string): ProjectConfig { - return new ProjectConfig({ + return buildProjectConfig({ name: 'test', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, database: { type: dbType, host: 'db', port: 5432, name: 'testdb', user: 'admin', password: 'secret', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { repositoryPath: '/repo', password: 'pass', snapshotMode: 'combined' }, retention: new RetentionPolicy(7, 7, 4, 3), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, }); } diff --git a/test/unit/infrastructure/adapters/health/system-health.adapter.spec.ts b/test/unit/infrastructure/adapters/health/system-health.adapter.spec.ts index 0fda3b4..90a6f97 100644 --- a/test/unit/infrastructure/adapters/health/system-health.adapter.spec.ts +++ b/test/unit/infrastructure/adapters/health/system-health.adapter.spec.ts @@ -70,95 +70,4 @@ describe('SystemHealthAdapter', () => { expect(result.freeGb).toBe(0); }); }); - - // ── checkSshConnectivity ─────────────────────────────────────────── - - describe('checkSshConnectivity', () => { - const sshConfig = { - host: 'storage.example.com', - port: 23, - user: 'u123456', - keyPath: '/ssh-keys/id_ed25519', - }; - - it('returns true when SSH succeeds', async () => { - mockedExec.mockResolvedValue({ stdout: '', stderr: '' }); - - const result = await adapter.checkSshConnectivity(sshConfig); - - expect(result).toBe(true); - expect(mockedExec).toHaveBeenCalledWith( - 'ssh', - expect.arrayContaining([ - '-i', '/ssh-keys/id_ed25519', - '-p', '23', - '-o', 'StrictHostKeyChecking=accept-new', - '-o', 'ConnectTimeout=5', - '-o', 'BatchMode=yes', - 'u123456@storage.example.com', - 'exit', - ]), - { timeout: 15000 }, - ); - }); - - it('returns true when restricted shell rejects exit command', async () => { - mockedExec.mockRejectedValue(new Error('Command not found')); - - const result = await adapter.checkSshConnectivity(sshConfig); - - expect(result).toBe(true); - }); - - it('returns false when SSH connection fails', async () => { - mockedExec.mockRejectedValue(new Error('ssh: connect to host storage.example.com port 23: Connection refused')); - - const result = await adapter.checkSshConnectivity(sshConfig); - - expect(result).toBe(false); - }); - - it('returns false on timeout', async () => { - mockedExec.mockRejectedValue(new Error('Command "ssh" failed: TIMEOUT')); - - const result = await adapter.checkSshConnectivity(sshConfig); - - expect(result).toBe(false); - }); - }); - - // ── checkSshAuthentication ───────────────────────────────────────── - - describe('checkSshAuthentication', () => { - it('returns true for a valid key file', async () => { - mockedExec.mockResolvedValue({ - stdout: '256 SHA256:abc123 user@host (ED25519)\n', - stderr: '', - }); - - const result = await adapter.checkSshAuthentication('/ssh-keys/id_ed25519'); - - expect(result).toBe(true); - expect(mockedExec).toHaveBeenCalledWith( - 'ssh-keygen', - ['-l', '-f', '/ssh-keys/id_ed25519'], - { timeout: 5000 }, - ); - }); - - it('returns false for an invalid/missing key file', async () => { - mockedExec.mockRejectedValue(new Error('/ssh-keys/missing: No such file or directory')); - - const result = await adapter.checkSshAuthentication('/ssh-keys/missing'); - - expect(result).toBe(false); - }); - - it('returns false when keyPath is empty', async () => { - const result = await adapter.checkSshAuthentication(''); - - expect(result).toBe(false); - expect(mockedExec).not.toHaveBeenCalled(); - }); - }); }); diff --git a/test/unit/infrastructure/adapters/notifiers/email-notifier.adapter.spec.ts b/test/unit/infrastructure/adapters/notifiers/email-notifier.adapter.spec.ts index 4b76447..647f067 100644 --- a/test/unit/infrastructure/adapters/notifiers/email-notifier.adapter.spec.ts +++ b/test/unit/infrastructure/adapters/notifiers/email-notifier.adapter.spec.ts @@ -37,12 +37,12 @@ describe('EmailNotifierAdapter', () => { function createSuccessResult(overrides?: Partial[0]>): BackupResult { return new BackupResult({ runId: 'run-123', - projectName: 'vinsware', + projectName: 'vinelab', status: BackupStatus.Success, currentStage: BackupStage.NotifyResult, startedAt: new Date('2026-03-18T00:00:00Z'), completedAt: new Date('2026-03-18T00:03:12Z'), - dumpResult: new DumpResult('/data/backups/vinsware/backup.sql.gz', 257949696, 45000), + dumpResult: new DumpResult('/data/backups/vinelab/backup.sql.gz', 257949696, 45000), syncResult: new SyncResult('a1b2c3d4', 12, 3, 54525952, 120000), pruneResult: new PruneResult(2, '150 MB'), cleanupResult: new CleanupResult(1, 1048576), @@ -86,12 +86,12 @@ describe('EmailNotifierAdapter', () => { describe('notifyStarted', () => { it('should send email with correct subject', async () => { - await adapter.notifyStarted('vinsware'); + await adapter.notifyStarted('vinelab'); expect(mockSendMail).toHaveBeenCalledTimes(1); expect(mockSendMail).toHaveBeenCalledWith( expect.objectContaining({ - subject: '🔄 Backup started — vinsware', + subject: '🔄 Backup started — vinelab', from: 'backup@company.com', to: 'devops@company.com', }), @@ -99,7 +99,7 @@ describe('EmailNotifierAdapter', () => { }); it('should include time in email body', async () => { - await adapter.notifyStarted('vinsware'); + await adapter.notifyStarted('vinelab'); const htmlBody = mockSendMail.mock.calls[0][0].html as string; expect(htmlBody).toContain('Backup started'); @@ -116,12 +116,12 @@ describe('EmailNotifierAdapter', () => { expect(mockSendMail).toHaveBeenCalledTimes(1); expect(mockSendMail).toHaveBeenCalledWith( expect.objectContaining({ - subject: '✅ Backup completed — vinsware', + subject: '✅ Backup completed — vinelab', }), ); const htmlBody = mockSendMail.mock.calls[0][0].html as string; - expect(htmlBody).toContain('Backup completed — vinsware'); + expect(htmlBody).toContain('Backup completed — vinelab'); expect(htmlBody).toContain('246.00 MB'); expect(htmlBody).toContain('Encrypted'); expect(htmlBody).toContain('Yes'); @@ -162,16 +162,16 @@ describe('EmailNotifierAdapter', () => { true, ); - await adapter.notifyFailure('vinsware', error); + await adapter.notifyFailure('vinelab', error); expect(mockSendMail).toHaveBeenCalledWith( expect.objectContaining({ - subject: '❌ Backup failed — vinsware', + subject: '❌ Backup failed — vinelab', }), ); const htmlBody = mockSendMail.mock.calls[0][0].html as string; - expect(htmlBody).toContain('Backup failed — vinsware'); + expect(htmlBody).toContain('Backup failed — vinelab'); expect(htmlBody).toContain('sync'); expect(htmlBody).toContain('connection timeout'); }); @@ -179,11 +179,11 @@ describe('EmailNotifierAdapter', () => { describe('notifyWarning', () => { it('should send email with warning subject and message', async () => { - await adapter.notifyWarning('vinsware', 'Backup exceeded timeout threshold'); + await adapter.notifyWarning('vinelab', 'Backup exceeded timeout threshold'); expect(mockSendMail).toHaveBeenCalledWith( expect.objectContaining({ - subject: '⚠️ Backup warning — vinsware', + subject: '⚠️ Backup warning — vinelab', }), ); @@ -214,7 +214,7 @@ describe('EmailNotifierAdapter', () => { expect(call.subject).toContain('📊 Daily Backup Summary'); const htmlBody = call.html as string; - expect(htmlBody).toContain('vinsware'); + expect(htmlBody).toContain('vinelab'); expect(htmlBody).toContain('project-y'); expect(htmlBody).toContain('FAILED'); expect(htmlBody).toContain('1/2 successful'); diff --git a/test/unit/infrastructure/adapters/notifiers/notification-formatter.spec.ts b/test/unit/infrastructure/adapters/notifiers/notification-formatter.spec.ts index 81598f1..ed2af20 100644 --- a/test/unit/infrastructure/adapters/notifiers/notification-formatter.spec.ts +++ b/test/unit/infrastructure/adapters/notifiers/notification-formatter.spec.ts @@ -96,7 +96,7 @@ describe('notification-formatter', () => { it('should build success entries', () => { const dump = new DumpResult('/data/db.sql.gz', 512, 1000); const sync = new SyncResult('snap-1', 1, 0, 512, 500); - const result = createResult({ projectName: 'vinsware', dumpResult: dump, syncResult: sync }); + const result = createResult({ projectName: 'vinelab', dumpResult: dump, syncResult: sync }); const entries = buildDailySummaryEntries([result]); expect(entries).toHaveLength(1); @@ -129,9 +129,9 @@ describe('notification-formatter', () => { describe('formatFailureText', () => { it('should format failure text with stage and retryable info', () => { const error = new BackupStageError(BackupStage.Dump, new Error('connection refused'), true); - const text = formatFailureText('vinsware', error); + const text = formatFailureText('vinelab', error); - expect(text).toContain('❌ Backup failed — vinsware'); + expect(text).toContain('❌ Backup failed — vinelab'); expect(text).toContain('Stage: dump'); expect(text).toContain('Retryable: Yes'); expect(text).toContain('Error: connection refused'); @@ -147,12 +147,12 @@ describe('notification-formatter', () => { describe('formatSuccessText', () => { it('should produce plaintext success with all sections', () => { - const dump = new DumpResult('/data/backups/vinsware.sql.gz', 1024 * 1024, 5000); + const dump = new DumpResult('/data/backups/vinelab.sql.gz', 1024 * 1024, 5000); const sync = new SyncResult('snap-abc', 10, 3, 2048, 2000); const prune = new PruneResult(2, '50 MB'); const cleanup = new CleanupResult(5, 10000); const result = createResult({ - projectName: 'vinsware', + projectName: 'vinelab', dumpResult: dump, syncResult: sync, pruneResult: prune, @@ -162,8 +162,8 @@ describe('notification-formatter', () => { }); const text = formatSuccessText(result); - expect(text).toContain('✅ Backup completed — vinsware'); - expect(text).toContain('DB: vinsware'); + expect(text).toContain('✅ Backup completed — vinelab'); + expect(text).toContain('DB: vinelab'); expect(text).toContain('Encrypted: Yes'); expect(text).toContain('Snapshot: snap-abc'); expect(text).toContain('Pruned: 2 snapshots'); @@ -182,7 +182,7 @@ describe('notification-formatter', () => { describe('formatDailySummaryText', () => { it('should produce daily summary with mixed results', () => { - const success = createResult({ projectName: 'vinsware', durationMs: 5000 }); + const success = createResult({ projectName: 'vinelab', durationMs: 5000 }); const failure = createResult({ projectName: 'shopify', status: BackupStatus.Failed, @@ -191,7 +191,7 @@ describe('notification-formatter', () => { const text = formatDailySummaryText([success, failure], '2026-03-19'); expect(text).toContain('📊 Daily Backup Summary — 2026-03-19'); - expect(text).toContain('✅ vinsware'); + expect(text).toContain('✅ vinelab'); expect(text).toContain('❌ shopify — FAILED — connection timeout'); expect(text).toContain('Total: 1/2 successful'); }); diff --git a/test/unit/infrastructure/adapters/notifiers/slack-notifier.adapter.spec.ts b/test/unit/infrastructure/adapters/notifiers/slack-notifier.adapter.spec.ts index 485f3cf..0170c0d 100644 --- a/test/unit/infrastructure/adapters/notifiers/slack-notifier.adapter.spec.ts +++ b/test/unit/infrastructure/adapters/notifiers/slack-notifier.adapter.spec.ts @@ -29,12 +29,12 @@ describe('SlackNotifierAdapter', () => { function createSuccessResult(overrides?: Partial[0]>): BackupResult { return new BackupResult({ runId: 'run-123', - projectName: 'vinsware', + projectName: 'vinelab', status: BackupStatus.Success, currentStage: BackupStage.NotifyResult, startedAt: new Date('2026-03-18T00:00:00Z'), completedAt: new Date('2026-03-18T00:03:12Z'), - dumpResult: new DumpResult('/data/backups/vinsware/backup.sql.gz', 257949696, 45000), + dumpResult: new DumpResult('/data/backups/vinelab/backup.sql.gz', 257949696, 45000), syncResult: new SyncResult('a1b2c3d4', 12, 3, 54525952, 120000), pruneResult: new PruneResult(2, '150 MB'), cleanupResult: new CleanupResult(1, 1048576), @@ -70,7 +70,7 @@ describe('SlackNotifierAdapter', () => { describe('notifyStarted', () => { it('should post Block Kit message with blue sidebar', async () => { - await adapter.notifyStarted('vinsware'); + await adapter.notifyStarted('vinelab'); expect(mockedAxios.post).toHaveBeenCalledTimes(1); @@ -81,11 +81,11 @@ describe('SlackNotifierAdapter', () => { const text = allBlockText(); expect(text).toContain('Backup started'); - expect(text).toContain('vinsware'); + expect(text).toContain('vinelab'); }); it('should include timestamp in context block', async () => { - await adapter.notifyStarted('vinsware'); + await adapter.notifyStarted('vinelab'); const text = allBlockText(); expect(text).toContain('🕐'); @@ -105,7 +105,7 @@ describe('SlackNotifierAdapter', () => { const text = allBlockText(); expect(text).toContain('Backup completed'); - expect(text).toContain('vinsware'); + expect(text).toContain('vinelab'); expect(text).toContain('246.00 MB'); expect(text).toContain('🔒 Yes'); expect(text).toContain('☑️ Yes'); @@ -172,21 +172,21 @@ describe('SlackNotifierAdapter', () => { true, ); - await adapter.notifyFailure('vinsware', error); + await adapter.notifyFailure('vinelab', error); expect(mockedAxios.post).toHaveBeenCalledTimes(1); expect(getAttachment().color).toBe('#e74c3c'); const text = allBlockText(); expect(text).toContain('Backup failed'); - expect(text).toContain('vinsware'); + expect(text).toContain('vinelab'); expect(text).toContain('sync'); expect(text).toContain('connection timeout to storage box'); }); it('should use minimal text to avoid duplicate with Block Kit', async () => { const error = new BackupStageError(BackupStage.Sync, new Error('timeout'), true); - await adapter.notifyFailure('vinsware', error); + await adapter.notifyFailure('vinelab', error); const { text } = getPayload() as { text: string }; expect(text).toBe(' '); @@ -194,7 +194,7 @@ describe('SlackNotifierAdapter', () => { it('should show retryable status in fields', async () => { const error = new BackupStageError(BackupStage.Sync, new Error('timeout'), true); - await adapter.notifyFailure('vinsware', error); + await adapter.notifyFailure('vinelab', error); const text = allBlockText(); expect(text).toContain('Retryable'); @@ -208,7 +208,7 @@ describe('SlackNotifierAdapter', () => { false, ); - await adapter.notifyFailure('vinsware', error); + await adapter.notifyFailure('vinelab', error); const text = allBlockText(); expect(text).toContain('Retryable'); @@ -225,19 +225,19 @@ describe('SlackNotifierAdapter', () => { describe('notifyWarning', () => { it('should post Block Kit message with orange sidebar', async () => { - await adapter.notifyWarning('vinsware', 'Backup timeout exceeded'); + await adapter.notifyWarning('vinelab', 'Backup timeout exceeded'); expect(mockedAxios.post).toHaveBeenCalledTimes(1); expect(getAttachment().color).toBe('#f39c12'); const text = allBlockText(); expect(text).toContain('Warning'); - expect(text).toContain('vinsware'); + expect(text).toContain('vinelab'); expect(text).toContain('Backup timeout exceeded'); }); it('should use minimal text to avoid duplicate with Block Kit', async () => { - await adapter.notifyWarning('vinsware', 'Backup timeout exceeded'); + await adapter.notifyWarning('vinelab', 'Backup timeout exceeded'); const { text } = getPayload() as { text: string }; expect(text).toBe(' '); @@ -273,7 +273,7 @@ describe('SlackNotifierAdapter', () => { const text = allBlockText(); expect(text).toContain('Daily Backup Summary'); - expect(text).toContain('vinsware'); + expect(text).toContain('vinelab'); expect(text).toContain('project-x'); expect(text).toContain('project-y'); expect(text).toContain('restic sync timeout'); diff --git a/test/unit/infrastructure/adapters/notifiers/webhook-notifier.adapter.spec.ts b/test/unit/infrastructure/adapters/notifiers/webhook-notifier.adapter.spec.ts index 34a57d6..c918fd7 100644 --- a/test/unit/infrastructure/adapters/notifiers/webhook-notifier.adapter.spec.ts +++ b/test/unit/infrastructure/adapters/notifiers/webhook-notifier.adapter.spec.ts @@ -40,12 +40,12 @@ describe('WebhookNotifierAdapter', () => { function createSuccessResult(overrides?: Partial[0]>): BackupResult { return new BackupResult({ runId: 'run-123', - projectName: 'vinsware', + projectName: 'vinelab', status: BackupStatus.Success, currentStage: BackupStage.NotifyResult, startedAt: new Date('2026-03-18T00:00:00Z'), completedAt: new Date('2026-03-18T00:03:12Z'), - dumpResult: new DumpResult('/data/backups/vinsware/backup.sql.gz', 257949696, 45000), + dumpResult: new DumpResult('/data/backups/vinelab/backup.sql.gz', 257949696, 45000), syncResult: new SyncResult('a1b2c3d4', 12, 3, 54525952, 120000), pruneResult: new PruneResult(2, '150 MB'), cleanupResult: new CleanupResult(1, 1048576), @@ -63,7 +63,7 @@ describe('WebhookNotifierAdapter', () => { describe('notifyStarted', () => { it('should post JSON with event=backup_started', async () => { - await adapter.notifyStarted('vinsware'); + await adapter.notifyStarted('vinelab'); expect(mockedAxios.post).toHaveBeenCalledTimes(1); const url = mockedAxios.post.mock.calls[0][0]; @@ -73,10 +73,10 @@ describe('WebhookNotifierAdapter', () => { expect(config).toEqual({ headers: { 'Content-Type': 'application/json' } }); expect(payload).toMatchObject({ event: 'backup_started', - project: 'vinsware', + project: 'vinelab', }); - expect(payload.text).toContain('🔄 Backup started — vinsware'); - expect(payload.data).toHaveProperty('project_name', 'vinsware'); + expect(payload.text).toContain('🔄 Backup started — vinelab'); + expect(payload.data).toHaveProperty('project_name', 'vinelab'); expect(payload.data).toHaveProperty('timestamp'); }); }); @@ -91,17 +91,17 @@ describe('WebhookNotifierAdapter', () => { const payload = getPayload(); expect(payload.event).toBe('backup_success'); - expect(payload.project).toBe('vinsware'); + expect(payload.project).toBe('vinelab'); // Verify text field contains formatted markdown - expect(payload.text).toContain('✅ Backup completed — vinsware'); + expect(payload.text).toContain('✅ Backup completed — vinelab'); expect(payload.text).toContain('Snapshot: a1b2c3d4'); expect(payload.text).toContain('Duration: 3m 12s'); // Verify data field includes structured backup result info expect(payload.data).toMatchObject({ run_id: 'run-123', - project_name: 'vinsware', + project_name: 'vinelab', status: 'success', snapshot_id: 'a1b2c3d4', dump_size_bytes: 257949696, @@ -121,15 +121,15 @@ describe('WebhookNotifierAdapter', () => { true, ); - await adapter.notifyFailure('vinsware', error); + await adapter.notifyFailure('vinelab', error); const payload = getPayload(); expect(payload.event).toBe('backup_failed'); - expect(payload.project).toBe('vinsware'); - expect(payload.text).toContain('❌ Backup failed — vinsware'); + expect(payload.project).toBe('vinelab'); + expect(payload.text).toContain('❌ Backup failed — vinelab'); expect(payload.text).toContain('connection timeout'); expect(payload.data).toMatchObject({ - project_name: 'vinsware', + project_name: 'vinelab', stage: BackupStage.Sync, is_retryable: true, error_message: 'connection timeout', @@ -139,14 +139,14 @@ describe('WebhookNotifierAdapter', () => { describe('notifyWarning', () => { it('should post JSON with event=backup_warning', async () => { - await adapter.notifyWarning('vinsware', 'Backup exceeded timeout'); + await adapter.notifyWarning('vinelab', 'Backup exceeded timeout'); const payload = getPayload(); expect(payload.event).toBe('backup_warning'); - expect(payload.project).toBe('vinsware'); + expect(payload.project).toBe('vinelab'); expect(payload.text).toBe('⚠️ Backup exceeded timeout'); expect(payload.data).toMatchObject({ - project_name: 'vinsware', + project_name: 'vinelab', message: 'Backup exceeded timeout', }); }); @@ -202,7 +202,7 @@ describe('WebhookNotifierAdapter', () => { const projects = payloadData.projects as Record[]; expect(projects).toHaveLength(2); expect(projects[0]).toMatchObject({ - project_name: 'vinsware', + project_name: 'vinelab', status: 'success', snapshot_id: 'a1b2c3d4', dump_size_bytes: 257949696, diff --git a/test/unit/infrastructure/adapters/storage/restic-storage.adapter.spec.ts b/test/unit/infrastructure/adapters/storage/restic-storage.adapter.spec.ts index 46ad0c1..fbac93c 100644 --- a/test/unit/infrastructure/adapters/storage/restic-storage.adapter.spec.ts +++ b/test/unit/infrastructure/adapters/storage/restic-storage.adapter.spec.ts @@ -12,29 +12,20 @@ import { safeExecFile } from '@common/helpers/child-process.util'; const mockedSafeExecFile = safeExecFile as jest.MockedFunction; describe('ResticStorageAdapter', () => { - const repositoryPath = '/backups/myproject'; + const repository = 'sftp:backup@storage.example.com:/backups/myproject'; const password = 'restic-secret'; - const sshHost = 'storage.example.com'; - const sshUser = 'backup'; - const sshKeyPath = '/root/.ssh/id_ed25519'; + const backendEnv = { RESTIC_SSH_COMMAND: 'ssh -i "/root/.ssh/id_ed25519" -p 22' }; const projectName = 'myproject'; let adapter: ResticStorageAdapter; beforeEach(() => { jest.clearAllMocks(); - adapter = new ResticStorageAdapter( - repositoryPath, - password, - sshHost, - sshUser, - sshKeyPath, - projectName, - ); + adapter = new ResticStorageAdapter(repository, password, backendEnv, projectName); }); - describe('RESTIC_REPOSITORY', () => { - it('should build correctly as sftp URL', async () => { + describe('restic env', () => { + it('should pass the resolved repository and password through', async () => { mockedSafeExecFile.mockResolvedValue({ stdout: '', stderr: '' }); await adapter.unlock(); @@ -43,9 +34,42 @@ describe('ResticStorageAdapter', () => { expect(callArgs[2]?.env).toEqual( expect.objectContaining({ RESTIC_REPOSITORY: 'sftp:backup@storage.example.com:/backups/myproject', + RESTIC_PASSWORD: 'restic-secret', }), ); }); + + it('should merge backend env into the restic environment', async () => { + mockedSafeExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + + await adapter.unlock(); + + const callArgs = mockedSafeExecFile.mock.calls[0]; + expect(callArgs[2]?.env).toEqual( + expect.objectContaining({ + RESTIC_SSH_COMMAND: 'ssh -i "/root/.ssh/id_ed25519" -p 22', + }), + ); + }); + + it('should carry whatever env the backend supplies, without knowing the backend', async () => { + const s3Adapter = new ResticStorageAdapter( + 's3:https://s3.example.com/bucket/repo', + password, + { AWS_ACCESS_KEY_ID: 'key', AWS_SECRET_ACCESS_KEY: 'secret' }, + projectName, + ); + mockedSafeExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + + await s3Adapter.unlock(); + + expect(mockedSafeExecFile.mock.calls[0][2]?.env).toEqual({ + RESTIC_REPOSITORY: 's3:https://s3.example.com/bucket/repo', + RESTIC_PASSWORD: 'restic-secret', + AWS_ACCESS_KEY_ID: 'key', + AWS_SECRET_ACCESS_KEY: 'secret', + }); + }); }); describe('sync', () => { diff --git a/test/unit/infrastructure/adapters/storage/restic-storage.factory.spec.ts b/test/unit/infrastructure/adapters/storage/restic-storage.factory.spec.ts index 30c64f9..7b95e64 100644 --- a/test/unit/infrastructure/adapters/storage/restic-storage.factory.spec.ts +++ b/test/unit/infrastructure/adapters/storage/restic-storage.factory.spec.ts @@ -1,13 +1,49 @@ import { ConfigService } from '@nestjs/config'; import { ResticStorageFactory } from '@domain/backup/infrastructure/adapters/storage/restic-storage.factory'; +import { ResticBackendRegistry } from '@domain/backup/infrastructure/adapters/storage/backends/restic-backend.registry'; +import { SftpBackendResolver } from '@domain/backup/infrastructure/adapters/storage/backends/sftp-backend.resolver'; +import { S3BackendResolver } from '@domain/backup/infrastructure/adapters/storage/backends/s3-backend.resolver'; +import { B2BackendResolver } from '@domain/backup/infrastructure/adapters/storage/backends/b2-backend.resolver'; +import { RcloneBackendResolver } from '@domain/backup/infrastructure/adapters/storage/backends/rclone-backend.resolver'; +import { LocalBackendResolver } from '@domain/backup/infrastructure/adapters/storage/backends/local-backend.resolver'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { StorageConfig } from '@domain/config/domain/storage-config.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; + +jest.mock('@common/helpers/child-process.util'); +import { safeExecFile } from '@common/helpers/child-process.util'; + +const mockedSafeExecFile = safeExecFile as jest.MockedFunction; describe('ResticStorageFactory', () => { let factory: ResticStorageFactory; let configValues: Record; + function createFactory(): ResticStorageFactory { + const mockConfigService = { + get: jest.fn((key: string, defaultValue?: unknown) => configValues[key] ?? defaultValue), + getOrThrow: jest.fn((key: string) => { + if (configValues[key] === undefined) throw new Error(`Config key "${key}" not found`); + return configValues[key]; + }), + } as unknown as ConfigService; + + const registry = new ResticBackendRegistry( + new SftpBackendResolver(mockConfigService), + new S3BackendResolver(), + new B2BackendResolver(), + new RcloneBackendResolver(mockConfigService), + new LocalBackendResolver(), + ); + + return new ResticStorageFactory(mockConfigService, registry); + } + beforeEach(() => { + mockedSafeExecFile.mockReset(); + mockedSafeExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + configValues = { HETZNER_SSH_HOST: 'storage.example.com', HETZNER_SSH_USER: 'u123', @@ -16,70 +52,250 @@ describe('ResticStorageFactory', () => { RESTIC_PASSWORD: 'global-pass', }; - const mockConfigService = { - get: jest.fn((key: string, defaultValue?: unknown) => configValues[key] ?? defaultValue), - getOrThrow: jest.fn((key: string) => { - if (configValues[key] === undefined) throw new Error(`Config key "${key}" not found`); - return configValues[key]; - }), - } as unknown as ConfigService; - - factory = new ResticStorageFactory(mockConfigService); + factory = createFactory(); }); function buildConfig(overrides: Partial<{ resticPassword: string; repoPath: string }> = {}): ProjectConfig { - return new ProjectConfig({ - name: 'test-project', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { type: 'postgres', host: 'db', port: 5432, name: 'testdb', user: 'u', password: 'p', dumpTimeoutMinutes: null }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { - repositoryPath: overrides.repoPath ?? 'backups/test-project', + return buildProjectConfig({ + storage: { + type: 'sftp', + repository: overrides.repoPath ?? '/backups/test-project', password: overrides.resticPassword ?? '', snapshotMode: 'combined', + config: {}, }, retention: new RetentionPolicy(7, 7, 4, 3), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, }); } + function buildStorageConfig(storage: Partial): ProjectConfig { + return buildProjectConfig({ + storage: { + type: 'sftp', + repository: '/backups/test-project', + password: 'restic-pass', + snapshotMode: 'combined', + config: {}, + ...storage, + }, + }); + } + + async function envFromCreatedStorage(config: ProjectConfig): Promise> { + const storage = factory.create(config); + await storage.unlock(); + return mockedSafeExecFile.mock.calls[0]![2]!.env as Record; + } + it('creates a RemoteStoragePort instance', () => { const storage = factory.create(buildConfig()); - expect(storage).toBeDefined(); expect(typeof storage.sync).toBe('function'); expect(typeof storage.prune).toBe('function'); }); - it('uses project-level restic password over global', () => { - const config = buildConfig({ resticPassword: 'project-pass' }); - const storage = factory.create(config); - expect(storage).toBeDefined(); + it('builds the sftp repository URL from SSH config and repository path', async () => { + const env = await envFromCreatedStorage(buildConfig({ repoPath: '/backups/vinelab' })); + + expect(env.RESTIC_REPOSITORY).toBe('sftp:u123@storage.example.com:/backups/vinelab'); }); - it('falls back to global RESTIC_PASSWORD when project password is empty', () => { - const config = buildConfig({ resticPassword: '' }); - const storage = factory.create(config); - expect(storage).toBeDefined(); + it('builds the SSH command with the key path and port', async () => { + const env = await envFromCreatedStorage(buildConfig()); + + expect(env.RESTIC_SSH_COMMAND).toBe( + 'ssh -i "/home/node/.ssh/id_ed25519" -p 23 -o StrictHostKeyChecking=accept-new', + ); + }); + + it('defaults the SSH port to 22 when not configured', async () => { + delete configValues['HETZNER_SSH_PORT']; + factory = createFactory(); + + const env = await envFromCreatedStorage(buildConfig()); + + expect(env.RESTIC_SSH_COMMAND).toContain('-p 22'); + }); + + it('uses project-level restic password over global', async () => { + const env = await envFromCreatedStorage(buildConfig({ resticPassword: 'project-pass' })); + + expect(env.RESTIC_PASSWORD).toBe('project-pass'); + }); + + it('falls back to global RESTIC_PASSWORD when project password is empty', async () => { + const env = await envFromCreatedStorage(buildConfig({ resticPassword: '' })); + + expect(env.RESTIC_PASSWORD).toBe('global-pass'); + }); + + it('throws when no password is configured at either level', () => { + delete configValues['RESTIC_PASSWORD']; + factory = createFactory(); + + expect(() => factory.create(buildConfig({ resticPassword: '' }))).toThrow( + 'Restic password not configured for project "test-project"', + ); }); it('throws when required SSH config is missing', () => { configValues['HETZNER_SSH_HOST'] = undefined; - const mockConfigService = { - get: jest.fn((key: string, defaultValue?: unknown) => configValues[key] ?? defaultValue), - getOrThrow: jest.fn((key: string) => { - if (configValues[key] === undefined) throw new Error(`Config key "${key}" not found`); - return configValues[key]; - }), - } as unknown as ConfigService; + factory = createFactory(); + + expect(() => factory.create(buildConfig())).toThrow('Config key "HETZNER_SSH_HOST" not found'); + }); - const brokenFactory = new ResticStorageFactory(mockConfigService); - expect(() => brokenFactory.create(buildConfig())).toThrow('Config key "HETZNER_SSH_HOST" not found'); + describe('s3 backend', () => { + const s3Config = { + type: 's3' as const, + repository: 'my-bucket/vinelab', + config: { + endpoint: 'https://s3.eu-central-003.backblazeb2.com', + access_key_id: 'key-id', + secret_access_key: 'secret-key', + }, + }; + + it('builds the s3 repository URL and credential env', async () => { + const env = await envFromCreatedStorage(buildStorageConfig(s3Config)); + + expect(env.RESTIC_REPOSITORY).toBe('s3:https://s3.eu-central-003.backblazeb2.com/my-bucket/vinelab'); + expect(env.AWS_ACCESS_KEY_ID).toBe('key-id'); + expect(env.AWS_SECRET_ACCESS_KEY).toBe('secret-key'); + }); + + it('does not leak the SSH command into a non-sftp backend', async () => { + const env = await envFromCreatedStorage(buildStorageConfig(s3Config)); + + expect(env.RESTIC_SSH_COMMAND).toBeUndefined(); + }); + + it('does not require Hetzner SSH config', async () => { + delete configValues['HETZNER_SSH_HOST']; + delete configValues['HETZNER_SSH_USER']; + delete configValues['HETZNER_SSH_KEY_PATH']; + factory = createFactory(); + + const env = await envFromCreatedStorage(buildStorageConfig(s3Config)); + + expect(env.RESTIC_REPOSITORY).toContain('s3:'); + }); + + it('tolerates a leading slash on the repository, as carried over from an sftp config', async () => { + const env = await envFromCreatedStorage(buildStorageConfig({ ...s3Config, repository: '/my-bucket/vinelab' })); + + expect(env.RESTIC_REPOSITORY).toBe('s3:https://s3.eu-central-003.backblazeb2.com/my-bucket/vinelab'); + }); + + it('strips a trailing slash from the endpoint', async () => { + const env = await envFromCreatedStorage( + buildStorageConfig({ ...s3Config, config: { ...s3Config.config, endpoint: 'https://s3.example.com/' } }), + ); + + expect(env.RESTIC_REPOSITORY).toBe('s3:https://s3.example.com/my-bucket/vinelab'); + }); + + it('passes region through when configured', async () => { + const env = await envFromCreatedStorage( + buildStorageConfig({ ...s3Config, config: { ...s3Config.config, region: 'eu-central-1' } }), + ); + + expect(env.AWS_DEFAULT_REGION).toBe('eu-central-1'); + }); + + it('omits region when not configured', async () => { + const env = await envFromCreatedStorage(buildStorageConfig(s3Config)); + + expect(env.AWS_DEFAULT_REGION).toBeUndefined(); + }); + + it('throws with an actionable message when credentials are missing', () => { + const config = buildStorageConfig({ ...s3Config, config: { endpoint: 'https://s3.example.com' } }); + + expect(() => factory.create(config)).toThrow('requires config.access_key_id'); + }); + }); + + describe('b2 backend', () => { + it('builds the b2 repository and credential env', async () => { + const env = await envFromCreatedStorage( + buildStorageConfig({ + type: 'b2', + repository: 'my-bucket:vinelab', + config: { account_id: 'acct', account_key: 'acct-key' }, + }), + ); + + expect(env.RESTIC_REPOSITORY).toBe('b2:my-bucket:vinelab'); + expect(env.B2_ACCOUNT_ID).toBe('acct'); + expect(env.B2_ACCOUNT_KEY).toBe('acct-key'); + }); + + it('rejects the s3-style bucket/path form, which b2 does not accept', () => { + const config = buildStorageConfig({ + type: 'b2', + repository: 'my-bucket/vinelab', + config: { account_id: 'acct', account_key: 'acct-key' }, + }); + + expect(() => factory.create(config)).toThrow('is not a valid b2 target'); + }); + }); + + describe('rclone backend', () => { + it('builds the rclone repository from a remote:path target', async () => { + const env = await envFromCreatedStorage( + buildStorageConfig({ type: 'rclone', repository: 'gdrive:backups/vinelab' }), + ); + + expect(env.RESTIC_REPOSITORY).toBe('rclone:gdrive:backups/vinelab'); + }); + + it('sets RCLONE_CONFIG from the project config bag', async () => { + const env = await envFromCreatedStorage( + buildStorageConfig({ + type: 'rclone', + repository: 'gdrive:backups/vinelab', + config: { config_path: '/custom/rclone.conf' }, + }), + ); + + expect(env.RCLONE_CONFIG).toBe('/custom/rclone.conf'); + }); + + it('falls back to RCLONE_CONFIG_PATH from env', async () => { + configValues['RCLONE_CONFIG_PATH'] = '/home/node/.config/rclone/rclone.conf'; + factory = createFactory(); + + const env = await envFromCreatedStorage( + buildStorageConfig({ type: 'rclone', repository: 'gdrive:backups/vinelab' }), + ); + + expect(env.RCLONE_CONFIG).toBe('/home/node/.config/rclone/rclone.conf'); + }); + + it('leaves RCLONE_CONFIG unset so rclone uses its default path', async () => { + const env = await envFromCreatedStorage( + buildStorageConfig({ type: 'rclone', repository: 'gdrive:backups/vinelab' }), + ); + + expect(env.RCLONE_CONFIG).toBeUndefined(); + }); + + it('rejects a repository that is not a remote:path target', () => { + const config = buildStorageConfig({ type: 'rclone', repository: 'backups/vinelab' }); + + expect(() => factory.create(config)).toThrow('is not a valid rclone target'); + }); + }); + + describe('local backend', () => { + it('uses the repository path verbatim with no extra env', async () => { + const env = await envFromCreatedStorage( + buildStorageConfig({ type: 'local', repository: '/srv/restic-repo' }), + ); + + expect(env.RESTIC_REPOSITORY).toBe('/srv/restic-repo'); + expect(env.RESTIC_SSH_COMMAND).toBeUndefined(); + }); }); }); diff --git a/test/unit/infrastructure/cli/commands/config.command.spec.ts b/test/unit/infrastructure/cli/commands/config.command.spec.ts index bdb584f..0371a06 100644 --- a/test/unit/infrastructure/cli/commands/config.command.spec.ts +++ b/test/unit/infrastructure/cli/commands/config.command.spec.ts @@ -8,13 +8,11 @@ import { import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig as buildBaseProjectConfig } from '@test/support/project-config.builder'; import { GpgKeyManagerAdapter as GpgKeyManager } from '@domain/backup/infrastructure/adapters/encryptors/gpg-key-manager.adapter'; function buildProjectConfig(): ProjectConfig { - return new ProjectConfig({ - name: 'test-project', - enabled: true, - cron: '0 2 * * *', + return buildBaseProjectConfig({ timeoutMinutes: 60, database: { type: 'postgres', @@ -25,19 +23,17 @@ function buildProjectConfig(): ProjectConfig { password: 'super-secret', dumpTimeoutMinutes: null, }, - compression: { enabled: true }, assets: { paths: ['/data/uploads'] }, - restic: { - repositoryPath: '/backups/test', + storage: { + type: 'sftp', + repository: '/backups/test', password: 'restic-secret', snapshotMode: 'combined', + config: {}, }, retention: new RetentionPolicy(7, 7, 4), - encryption: null, - hooks: null, verification: { enabled: true }, notification: { type: 'slack', config: {} }, - monitor: null, }); } @@ -113,25 +109,58 @@ describe('ConfigShowSubCommand', () => { const output = (console.log as jest.Mock).mock.calls[0][0] as string; const parsed = JSON.parse(output) as Record; expect((parsed.database as Record).password).toBe('********'); - expect((parsed.restic as Record).password).toBe('********'); + expect((parsed.storage as Record).password).toBe('********'); + }); + + it('should mask secret values inside the storage config bag', async () => { + configLoader.getProject.mockReturnValue( + buildBaseProjectConfig({ + storage: { + type: 's3', + repository: 'my-bucket/test', + password: 'restic-secret', + snapshotMode: 'combined', + config: { + endpoint: 'https://s3.example.com', + access_key_id: 'AKIAEXAMPLE', + secret_access_key: 'super-secret-value', + }, + }, + }), + ); + + await command.run(['test-project']); + + const output = (console.log as jest.Mock).mock.calls[0][0] as string; + const storageConfig = (JSON.parse(output) as { storage: { config: Record } }).storage.config; + + expect(storageConfig.endpoint).toBe('https://s3.example.com'); + expect(storageConfig.secret_access_key).toBe('********'); + expect(storageConfig.access_key_id).toBe('********'); + expect(output).not.toContain('super-secret-value'); + }); + + it('should mask notification webhook url', async () => { + configLoader.getProject.mockReturnValue( + buildBaseProjectConfig({ + notification: { type: 'slack', config: { webhook_url: 'https://hooks.slack.com/services/SECRET' } }, + }), + ); + + await command.run(['test-project']); + + const output = (console.log as jest.Mock).mock.calls[0][0] as string; + expect(output).not.toContain('hooks.slack.com'); }); it('should print null database for files-only project', async () => { - const filesOnlyConfig = new ProjectConfig({ + const filesOnlyConfig = buildBaseProjectConfig({ name: 'static-assets', - enabled: true, cron: '0 3 * * *', - timeoutMinutes: null, database: null, - compression: { enabled: true }, assets: { paths: ['/data/uploads'] }, - restic: { repositoryPath: '/backups/test', password: 'restic-secret', snapshotMode: 'combined' }, + storage: { type: 'sftp', repository: '/backups/test', password: 'restic-secret', snapshotMode: 'combined', config: {} }, retention: new RetentionPolicy(7, 7, 4), - encryption: null, - hooks: null, - verification: { enabled: false }, - notification: null, - monitor: null, }); configLoader.getProject.mockReturnValue(filesOnlyConfig); diff --git a/test/unit/infrastructure/cli/commands/health.command.spec.ts b/test/unit/infrastructure/cli/commands/health.command.spec.ts index d221230..80759b8 100644 --- a/test/unit/infrastructure/cli/commands/health.command.spec.ts +++ b/test/unit/infrastructure/cli/commands/health.command.spec.ts @@ -1,6 +1,6 @@ import { HealthCommand } from '@domain/health/presenters/cli/health.command'; import { CheckHealthUseCase } from '@domain/health/application/use-cases/check-health/check-health.use-case'; -import { HealthCheckResult } from '@domain/audit/domain/health-check-result.model'; +import { buildHealthCheckResult, buildStorageHealthCheck } from '@test/support/health-check-result.builder'; describe('HealthCommand', () => { let command: HealthCommand; @@ -24,7 +24,7 @@ describe('HealthCommand', () => { it('should print healthy status when all checks pass', async () => { checkHealth.execute.mockResolvedValue( - new HealthCheckResult(true, true, 50, true, true, true, 3600), + buildHealthCheckResult(), ); await command.run([]); @@ -35,7 +35,7 @@ describe('HealthCommand', () => { it('should set exit code 1 when unhealthy', async () => { checkHealth.execute.mockResolvedValue( - new HealthCheckResult(false, true, 50, true, true, true, 3600), + buildHealthCheckResult({ auditDbConnected: false }), ); await command.run([]); @@ -46,7 +46,7 @@ describe('HealthCommand', () => { it('should display individual check results', async () => { checkHealth.execute.mockResolvedValue( - new HealthCheckResult(true, true, 42, true, true, true, 7200), + buildHealthCheckResult({ diskFreeGb: 42, uptime: 7200 }), ); await command.run([]); @@ -55,9 +55,48 @@ describe('HealthCommand', () => { expect(console.log).toHaveBeenCalledWith(expect.stringContaining('42 GB free')); }); + it('should print a line per project with its backend type', async () => { + checkHealth.execute.mockResolvedValue( + buildHealthCheckResult({ + storageChecks: [ + buildStorageHealthCheck({ project: 'vinelab', backendType: 's3' }), + buildStorageHealthCheck({ project: 'project-x', backendType: 'rclone' }), + ], + }), + ); + + await command.run([]); + + const printed = (console.log as jest.Mock).mock.calls.map((call) => String(call[0])).join('\n'); + expect(printed).toContain('Storage: vinelab (s3)'); + expect(printed).toContain('Storage: project-x (rclone)'); + }); + + it('should show the backend error and exit 1 when a repo is unreachable', async () => { + checkHealth.execute.mockResolvedValue( + buildHealthCheckResult({ + storageChecks: [ + buildStorageHealthCheck({ + project: 'vinelab', + backendType: 's3', + reachable: false, + error: 'Fatal: unable to open config file', + }), + ], + }), + ); + + await command.run([]); + + const printed = (console.log as jest.Mock).mock.calls.map((call) => String(call[0])).join('\n'); + expect(printed).toContain('Fatal: unable to open config file'); + expect(console.log).toHaveBeenCalledWith('System unhealthy'); + expect(process.exitCode).toBe(1); + }); + it('should print Uptime Kuma line when configured', async () => { checkHealth.execute.mockResolvedValue( - new HealthCheckResult(true, true, 50, true, true, true, 3600, true, true, true), + buildHealthCheckResult({ uptimeKumaConfigured: true, uptimeKumaConnected: true }), ); await command.run([]); @@ -67,7 +106,7 @@ describe('HealthCommand', () => { it('should not print Uptime Kuma when not configured', async () => { checkHealth.execute.mockResolvedValue( - new HealthCheckResult(true, true, 50, true, true, true, 3600), + buildHealthCheckResult(), ); await command.run([]); diff --git a/test/unit/infrastructure/cli/commands/restic.command.spec.ts b/test/unit/infrastructure/cli/commands/restic.command.spec.ts index 8b5078b..8e40370 100644 --- a/test/unit/infrastructure/cli/commands/restic.command.spec.ts +++ b/test/unit/infrastructure/cli/commands/restic.command.spec.ts @@ -4,28 +4,16 @@ import { RemoteStorageFactoryPort } from '@domain/backup/application/ports/remot import { RemoteStoragePort } from '@domain/backup/application/ports/remote-storage.port'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig as buildBaseProjectConfig } from '@test/support/project-config.builder'; function buildProjectConfig(): ProjectConfig { - return new ProjectConfig({ - name: 'test-project', - enabled: true, - cron: '0 2 * * *', - timeoutMinutes: null, - database: { - type: 'postgres', - host: 'localhost', - port: 5432, - name: 'testdb', - user: 'admin', - password: 'secret', - dumpTimeoutMinutes: null, - }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { - repositoryPath: '/backups/test', + return buildBaseProjectConfig({ + storage: { + type: 'sftp', + repository: '/backups/test', password: 'restic-pass', snapshotMode: 'combined', + config: {}, }, retention: new RetentionPolicy(7, 7, 4), encryption: null, diff --git a/test/unit/infrastructure/http/health.controller.spec.ts b/test/unit/infrastructure/http/health.controller.spec.ts index f1b9cfa..8c30426 100644 --- a/test/unit/infrastructure/http/health.controller.spec.ts +++ b/test/unit/infrastructure/http/health.controller.spec.ts @@ -1,23 +1,93 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { HealthController } from '@domain/health/presenters/http/health.controller'; import { CheckHealthUseCase } from '@domain/health/application/use-cases/check-health/check-health.use-case'; -import { HealthCheckResult } from '@domain/audit/domain/health-check-result.model'; +import { CheckLivenessUseCase } from '@domain/health/application/use-cases/check-liveness/check-liveness.use-case'; +import { LivenessResult } from '@domain/health/domain/liveness-result.model'; +import { buildHealthCheckResult, buildStorageHealthCheck } from '@test/support/health-check-result.builder'; describe('HealthController', () => { let controller: HealthController; let checkHealth: jest.Mocked; + let checkLiveness: jest.Mocked; beforeEach(() => { checkHealth = { execute: jest.fn(), } as unknown as jest.Mocked; - controller = new HealthController(checkHealth); + checkLiveness = { + execute: jest.fn().mockResolvedValue( + new LivenessResult({ + auditDbConnected: true, + diskSpaceAvailable: true, + diskFreeGb: 50, + uptime: 3600, + }), + ), + } as unknown as jest.Mocked; + + controller = new HealthController(checkHealth, checkLiveness); + }); + + describe('GET /health/live', () => { + it('returns healthy without probing remote storage', async () => { + const body = await controller.live(); + + expect(body).toEqual({ + status: 'healthy', + checks: { auditDb: true, diskSpace: { available: true, freeGb: 50 } }, + uptime: 3600, + }); + expect(checkHealth.execute).not.toHaveBeenCalled(); + }); + + it('stays healthy when a storage backend is down, so Docker will not restart the container', async () => { + checkHealth.execute.mockResolvedValue( + buildHealthCheckResult({ + storageChecks: [buildStorageHealthCheck({ reachable: false, error: 'B2 outage' })], + }), + ); + + const body = await controller.live(); + + expect(body.status).toBe('healthy'); + expect(checkHealth.execute).not.toHaveBeenCalled(); + }); + + it('returns 503 when the audit DB is down', async () => { + checkLiveness.execute.mockResolvedValue( + new LivenessResult({ + auditDbConnected: false, + diskSpaceAvailable: true, + diskFreeGb: 50, + uptime: 3600, + }), + ); + + await expect(controller.live()).rejects.toThrow(HttpException); + }); + + it('returns 503 when disk space is low', async () => { + checkLiveness.execute.mockResolvedValue( + new LivenessResult({ + auditDbConnected: true, + diskSpaceAvailable: false, + diskFreeGb: 1, + uptime: 3600, + }), + ); + + try { + await controller.live(); + fail('Expected HttpException to be thrown'); + } catch (error) { + expect((error as HttpException).getStatus()).toBe(HttpStatus.SERVICE_UNAVAILABLE); + } + }); }); it('should return healthy response when all checks pass', async () => { - const healthyResult = new HealthCheckResult(true, true, 50, true, true, true, 3600); - checkHealth.execute.mockResolvedValue(healthyResult); + checkHealth.execute.mockResolvedValue(buildHealthCheckResult()); const body = await controller.check(); @@ -25,15 +95,14 @@ describe('HealthController', () => { expect(body.checks.auditDb).toBe(true); expect(body.checks.diskSpace.available).toBe(true); expect(body.checks.diskSpace.freeGb).toBe(50); - expect(body.checks.ssh.connected).toBe(true); - expect(body.checks.ssh.authenticated).toBe(true); - expect(body.checks.resticRepos).toBe(true); + expect(body.checks.storage).toEqual([ + { project: 'vinelab', backendType: 'sftp', reachable: true }, + ]); expect(body.uptime).toBe(3600); }); it('should throw 503 HttpException when audit DB is down', async () => { - const unhealthyResult = new HealthCheckResult(false, true, 50, true, true, true, 3600); - checkHealth.execute.mockResolvedValue(unhealthyResult); + checkHealth.execute.mockResolvedValue(buildHealthCheckResult({ auditDbConnected: false })); try { await controller.check(); @@ -48,8 +117,9 @@ describe('HealthController', () => { }); it('should throw 503 HttpException when disk space is low', async () => { - const unhealthyResult = new HealthCheckResult(true, false, 1, true, true, true, 3600); - checkHealth.execute.mockResolvedValue(unhealthyResult); + checkHealth.execute.mockResolvedValue( + buildHealthCheckResult({ diskSpaceAvailable: false, diskFreeGb: 1 }), + ); try { await controller.check(); @@ -65,9 +135,19 @@ describe('HealthController', () => { } }); - it('should throw 503 HttpException when SSH is disconnected', async () => { - const unhealthyResult = new HealthCheckResult(true, true, 50, false, false, true, 3600); - checkHealth.execute.mockResolvedValue(unhealthyResult); + it('should throw 503 HttpException when a storage backend is unreachable', async () => { + checkHealth.execute.mockResolvedValue( + buildHealthCheckResult({ + storageChecks: [ + buildStorageHealthCheck({ + project: 'vinelab', + backendType: 's3', + reachable: false, + error: 'Fatal: unable to open config file', + }), + ], + }), + ); try { await controller.check(); @@ -77,15 +157,51 @@ describe('HealthController', () => { expect(exception.getStatus()).toBe(HttpStatus.SERVICE_UNAVAILABLE); const response = exception.getResponse() as Record; const checks = response.checks as Record; - const ssh = checks.ssh as Record; - expect(ssh.connected).toBe(false); - expect(ssh.authenticated).toBe(false); + expect(checks.storage).toEqual([ + { + project: 'vinelab', + backendType: 's3', + reachable: false, + error: 'Fatal: unable to open config file', + }, + ]); + } + }); + + it('should report healthy when no projects are configured', async () => { + checkHealth.execute.mockResolvedValue(buildHealthCheckResult({ storageChecks: [] })); + + const body = await controller.check(); + + expect(body.status).toBe('healthy'); + expect(body.checks.storage).toEqual([]); + }); + + it('should report per-project results for mixed backends', async () => { + checkHealth.execute.mockResolvedValue( + buildHealthCheckResult({ + storageChecks: [ + buildStorageHealthCheck({ project: 'a', backendType: 's3', reachable: true }), + buildStorageHealthCheck({ project: 'b', backendType: 'rclone', reachable: false, error: 'token expired' }), + ], + }), + ); + + try { + await controller.check(); + fail('Expected HttpException to be thrown'); + } catch (error) { + const response = (error as HttpException).getResponse() as Record; + const checks = response.checks as Record; + expect(checks.storage).toEqual([ + { project: 'a', backendType: 's3', reachable: true }, + { project: 'b', backendType: 'rclone', reachable: false, error: 'token expired' }, + ]); } }); it('should match expected response shape', async () => { - const result = new HealthCheckResult(true, true, 25, true, true, true, 120); - checkHealth.execute.mockResolvedValue(result); + checkHealth.execute.mockResolvedValue(buildHealthCheckResult({ diskFreeGb: 25, uptime: 120 })); const body = await controller.check(); @@ -94,27 +210,16 @@ describe('HealthController', () => { checks: { auditDb: true, diskSpace: { available: true, freeGb: 25 }, - ssh: { connected: true, authenticated: true }, - resticRepos: true, + storage: [{ project: 'vinelab', backendType: 'sftp', reachable: true }], }, uptime: 120, }); }); it('should include uptimeKuma in response when configured', async () => { - const result = new HealthCheckResult( - true, - true, - 50, - true, - true, - true, - 3600, - true, - true, - true, + checkHealth.execute.mockResolvedValue( + buildHealthCheckResult({ uptimeKumaConfigured: true, uptimeKumaConnected: true }), ); - checkHealth.execute.mockResolvedValue(result); const body = await controller.check(); @@ -123,19 +228,9 @@ describe('HealthController', () => { }); it('should include uptimeKuma when configured but disconnected without affecting healthy status', async () => { - const result = new HealthCheckResult( - true, - true, - 50, - true, - true, - true, - 3600, - true, - false, - true, + checkHealth.execute.mockResolvedValue( + buildHealthCheckResult({ uptimeKumaConfigured: true, uptimeKumaConnected: false }), ); - checkHealth.execute.mockResolvedValue(result); const body = await controller.check(); diff --git a/test/unit/infrastructure/http/status.controller.spec.ts b/test/unit/infrastructure/http/status.controller.spec.ts index cbec9e3..327428c 100644 --- a/test/unit/infrastructure/http/status.controller.spec.ts +++ b/test/unit/infrastructure/http/status.controller.spec.ts @@ -41,7 +41,7 @@ describe('StatusController', () => { describe('getAllStatus', () => { it('should return all project statuses', async () => { - const mockResults = [createMockResult('vinsware'), createMockResult('shopify')]; + const mockResults = [createMockResult('vinelab'), createMockResult('shopify')]; getBackupStatus.execute.mockResolvedValue(mockResults); const response = await controller.getAllStatus(); @@ -75,24 +75,24 @@ describe('StatusController', () => { describe('getProjectStatus', () => { it('should return specific project history', async () => { - const mockResults = [createMockResult('vinsware')]; + const mockResults = [createMockResult('vinelab')]; getBackupStatus.execute.mockResolvedValue(mockResults); - const response = await controller.getProjectStatus('vinsware'); + const response = await controller.getProjectStatus('vinelab'); expect(getBackupStatus.execute).toHaveBeenCalledWith( - expect.objectContaining({ projectName: 'vinsware', limit: undefined }), + expect.objectContaining({ projectName: 'vinelab', limit: undefined }), ); - expect(response).toEqual({ project: 'vinsware', history: mockResults }); + expect(response).toEqual({ project: 'vinelab', history: mockResults }); }); it('should pass parsed limit for project status', async () => { getBackupStatus.execute.mockResolvedValue([]); - await controller.getProjectStatus('vinsware', '10'); + await controller.getProjectStatus('vinelab', '10'); expect(getBackupStatus.execute).toHaveBeenCalledWith( - expect.objectContaining({ projectName: 'vinsware', limit: 10 }), + expect.objectContaining({ projectName: 'vinelab', limit: 10 }), ); }); }); diff --git a/test/unit/infrastructure/scheduler/dynamic-scheduler.service.spec.ts b/test/unit/infrastructure/scheduler/dynamic-scheduler.service.spec.ts index 46c7fe1..74e3254 100644 --- a/test/unit/infrastructure/scheduler/dynamic-scheduler.service.spec.ts +++ b/test/unit/infrastructure/scheduler/dynamic-scheduler.service.spec.ts @@ -8,6 +8,7 @@ import { BackupLockPort } from '@domain/backup/application/ports/backup-lock.por import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; import { ProjectConfig } from '@domain/config/domain/project-config.model'; import { RetentionPolicy } from '@domain/config/domain/retention-policy.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; import { BackupResult } from '@domain/backup/domain/backup-result.model'; import { BackupStatus } from '@domain/backup/domain/value-objects/backup-status.enum'; import { BackupStage } from '@domain/backup/domain/value-objects/backup-stage.enum'; @@ -15,27 +16,10 @@ import { RunBackupCommand } from '@domain/backup/application/use-cases/run-backu import { NotifierPort } from '@domain/notification/application/ports/notifier.port'; function createProjectConfig(overrides: Partial<{ name: string; cron: string; enabled: boolean }> = {}): ProjectConfig { - return new ProjectConfig({ + return buildProjectConfig({ name: overrides.name ?? 'test-project', enabled: overrides.enabled ?? true, cron: overrides.cron ?? '0 2 * * *', - timeoutMinutes: null, - database: { - type: 'postgres', - host: 'localhost', - port: 5432, - name: 'testdb', - user: 'testuser', - password: 'testpass', - dumpTimeoutMinutes: null, - }, - compression: { enabled: true }, - assets: { paths: [] }, - restic: { - repositoryPath: '/repo/test', - password: 'restic-pass', - snapshotMode: 'combined', - }, retention: new RetentionPolicy(3, 7, 4, 6), encryption: null, hooks: null, @@ -146,14 +130,14 @@ describe('DynamicSchedulerService', () => { describe('onModuleInit', () => { it('should register cron jobs for each enabled project', async () => { - const vinsware = createProjectConfig({ name: 'vinsware', cron: '0 2 * * *' }); + const vinelab = createProjectConfig({ name: 'vinelab', cron: '0 2 * * *' }); const shopify = createProjectConfig({ name: 'shopify', cron: '0 3 * * *' }); - configLoader.loadAll.mockReturnValue([vinsware, shopify]); + configLoader.loadAll.mockReturnValue([vinelab, shopify]); await service.onModuleInit(); expect(schedulerRegistry.addCronJob).toHaveBeenCalledWith( - 'backup-vinsware', + 'backup-vinelab', expect.objectContaining({ cronTime: expect.anything() }), ); expect(schedulerRegistry.addCronJob).toHaveBeenCalledWith( @@ -188,25 +172,25 @@ describe('DynamicSchedulerService', () => { describe('executeScheduledBackup', () => { it('should acquire lock, run backup with lockHeldExternally, then release', async () => { - await service.executeScheduledBackup('vinsware'); + await service.executeScheduledBackup('vinelab'); - expect(backupLock.acquireOrQueue).toHaveBeenCalledWith('vinsware'); + expect(backupLock.acquireOrQueue).toHaveBeenCalledWith('vinelab'); expect(runBackup.execute).toHaveBeenCalledWith( expect.objectContaining({ - projectName: 'vinsware', + projectName: 'vinelab', lockHeldExternally: true, }), ); - expect(backupLock.release).toHaveBeenCalledWith('vinsware'); + expect(backupLock.release).toHaveBeenCalledWith('vinelab'); }); it('should release lock even when backup throws', async () => { runBackup.execute.mockRejectedValueOnce(new Error('dump failed')); - await expect(service.executeScheduledBackup('vinsware')).rejects.toThrow('dump failed'); + await expect(service.executeScheduledBackup('vinelab')).rejects.toThrow('dump failed'); - expect(backupLock.acquireOrQueue).toHaveBeenCalledWith('vinsware'); - expect(backupLock.release).toHaveBeenCalledWith('vinsware'); + expect(backupLock.acquireOrQueue).toHaveBeenCalledWith('vinelab'); + expect(backupLock.release).toHaveBeenCalledWith('vinelab'); }); it('should pass correct RunBackupCommand', async () => { @@ -231,7 +215,7 @@ describe('DynamicSchedulerService', () => { it('should send daily summary to all registered notifiers', async () => { const recentResult = createBackupResult({ - projectName: 'vinsware', + projectName: 'vinelab', startedAt: new Date(), }); getBackupStatus.execute.mockResolvedValue([recentResult]); @@ -272,14 +256,14 @@ describe('DynamicSchedulerService', () => { describe('backup cron callback', () => { it('registers backup job with correct name for each project', async () => { - const vinsware = createProjectConfig({ name: 'vinsware' }); + const vinelab = createProjectConfig({ name: 'vinelab' }); const shopify = createProjectConfig({ name: 'shopify' }); - configLoader.loadAll.mockReturnValue([vinsware, shopify]); + configLoader.loadAll.mockReturnValue([vinelab, shopify]); await service.onModuleInit(); const jobNames = schedulerRegistry.addCronJob.mock.calls.map((call) => call[0] as string); - expect(jobNames).toContain('backup-vinsware'); + expect(jobNames).toContain('backup-vinelab'); expect(jobNames).toContain('backup-shopify'); expect(jobNames).toContain('daily-summary'); }); diff --git a/test/unit/shared/env-validation.service.spec.ts b/test/unit/shared/env-validation.service.spec.ts index 33ff6d9..825f64b 100644 --- a/test/unit/shared/env-validation.service.spec.ts +++ b/test/unit/shared/env-validation.service.spec.ts @@ -1,5 +1,8 @@ import { ConfigService } from '@nestjs/config'; import { EnvValidationService } from '@common/validation/env-validation.service'; +import { ConfigLoaderPort } from '@domain/config/application/ports/config-loader.port'; +import { StorageBackendType } from '@domain/config/domain/storage-config.model'; +import { buildProjectConfig } from '@test/support/project-config.builder'; function createConfigService(env: Record): ConfigService { return { @@ -7,58 +10,114 @@ function createConfigService(env: Record): ConfigService { } as unknown as ConfigService; } +function createConfigLoader( + projects: Array<{ type: StorageBackendType; enabled?: boolean }> = [{ type: 'sftp' }], +): ConfigLoaderPort { + return { + loadAll: jest.fn(() => + projects.map((project) => + buildProjectConfig({ + enabled: project.enabled ?? true, + storage: { + type: project.type, + repository: 'repo', + password: 'pass', + snapshotMode: 'combined', + config: {}, + }, + }), + ), + ), + getProject: jest.fn(), + validate: jest.fn(), + reload: jest.fn(), + }; +} + +function createService(env: Record, loader = createConfigLoader()): EnvValidationService { + return new EnvValidationService(createConfigService(env), loader); +} + describe('EnvValidationService', () => { it('throws in production when required vars are missing', () => { - const service = new EnvValidationService( - createConfigService({ NODE_ENV: 'production' }), - ); + const service = createService({ NODE_ENV: 'production' }); expect(() => service.onModuleInit()).toThrow('Missing required environment variables'); }); it('does not throw in production when all required vars are set', () => { - const service = new EnvValidationService( - createConfigService({ - NODE_ENV: 'production', - AUDIT_DB_HOST: 'db.example.com', - AUDIT_DB_PASSWORD: 'secret', - HETZNER_SSH_HOST: 'storage.example.com', - HETZNER_SSH_USER: 'u123456', - HETZNER_SSH_KEY_PATH: '/ssh-keys/id_ed25519', - }), - ); + const service = createService({ + NODE_ENV: 'production', + AUDIT_DB_HOST: 'db.example.com', + AUDIT_DB_PASSWORD: 'secret', + HETZNER_SSH_HOST: 'storage.example.com', + HETZNER_SSH_USER: 'u123456', + HETZNER_SSH_KEY_PATH: '/ssh-keys/id_ed25519', + }); expect(() => service.onModuleInit()).not.toThrow(); }); it('does not throw in development even with missing vars', () => { - const service = new EnvValidationService( - createConfigService({ NODE_ENV: 'development' }), - ); + const service = createService({ NODE_ENV: 'development' }); expect(() => service.onModuleInit()).not.toThrow(); }); it('skips validation in CLI mode', () => { - const service = new EnvValidationService( - createConfigService({ - NODE_ENV: 'production', - BACKUPCTL_CLI_MODE: '1', - }), - ); + const service = createService({ NODE_ENV: 'production', BACKUPCTL_CLI_MODE: '1' }); expect(() => service.onModuleInit()).not.toThrow(); }); it('includes missing var names in error message', () => { - const service = new EnvValidationService( - createConfigService({ - NODE_ENV: 'production', - AUDIT_DB_HOST: 'db.example.com', - }), - ); + const service = createService({ NODE_ENV: 'production', AUDIT_DB_HOST: 'db.example.com' }); expect(() => service.onModuleInit()).toThrow('AUDIT_DB_PASSWORD'); expect(() => service.onModuleInit()).toThrow('HETZNER_SSH_HOST'); }); + + describe('backend-conditional SSH vars', () => { + const productionCore = { + NODE_ENV: 'production', + AUDIT_DB_HOST: 'db.example.com', + AUDIT_DB_PASSWORD: 'secret', + }; + + it('boots an S3-only deployment with no Hetzner SSH vars set', () => { + const service = createService(productionCore, createConfigLoader([{ type: 's3' }])); + + expect(() => service.onModuleInit()).not.toThrow(); + }); + + it('still requires SSH vars when any enabled project uses sftp', () => { + const service = createService(productionCore, createConfigLoader([{ type: 's3' }, { type: 'sftp' }])); + + expect(() => service.onModuleInit()).toThrow('HETZNER_SSH_HOST'); + }); + + it('ignores disabled sftp projects', () => { + const service = createService( + productionCore, + createConfigLoader([{ type: 's3' }, { type: 'sftp', enabled: false }]), + ); + + expect(() => service.onModuleInit()).not.toThrow(); + }); + + it('does not fail boot when the project config cannot be read', () => { + const brokenLoader: ConfigLoaderPort = { + loadAll: jest.fn(() => { + throw new Error('projects.yml is malformed'); + }), + getProject: jest.fn(), + validate: jest.fn(), + reload: jest.fn(), + }; + + const service = createService(productionCore, brokenLoader); + + expect(() => service.onModuleInit()).not.toThrow(); + }); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 28f0bd3..4115d28 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,8 @@ "esModuleInterop": true, "paths": { "@domain/*": ["src/domain/*"], - "@common/*": ["src/common/*"] + "@common/*": ["src/common/*"], + "@test/*": ["test/*"] } }, "include": ["src/**/*", "test/**/*"],