Compare commits

...

47 Commits

Author SHA1 Message Date
Mauricio Siu 12784ec0ff Merge remote-tracking branch 'origin/canary' into feature/managed-servers
# Conflicts:
#	apps/dokploy/drizzle/meta/0167_snapshot.json
#	apps/dokploy/drizzle/meta/0168_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
#	apps/dokploy/server/api/root.ts
#	pnpm-lock.yaml
2026-09-28 09:36:15 -06: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
Dokploy Bot f397c1a862 chore: sync hotfix from main into canary [skip ci] 2026-09-18 09:04:20 +00:00
Mauricio Siu d7d75f7c5c Merge pull request #5477 from Dokploy/feat/trial-expiration-emails
fix(billing): pick a plan for the trial and warn before it expires
2026-09-18 03:04:07 -06:00
Mauricio Siu a0e1001808 feat(trial-notifications): add email notification for expiring trials and integrate with webhook 2026-09-18 03:03:51 -06:00
Mauricio Siu 8236c31fcd Revert "fix(billing): one reminder at 1 day, guarded by an advisory lock"
Revert "fix(billing): stop the reminder claim from rewriting stale metadata"

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

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

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

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

- startFreeTrial takes a tier; TRIAL_SERVER_LIMIT becomes TRIAL_SERVER_LIMITS
  (Hobby 1 server, Startup 3)
- onboarding offers both plans with a trial and a subscribe button
- billing exposes Switch to Startup/Hobby during the trial and hides the
  checkout CTAs and server steppers that would create a second subscription
- upgradeSubscription caps the server count to the plan's included servers
  while trialing and skips proration, which emitted an orphan invoice
- webhook treats trialing as active instead of disabling servers, and
  guards invoices that carry no subscription
- getBillingStatus reports hasPaymentMethod, so the banners stop asking for
  a card once one is on file
- new cloud-only daily job emails trial users without a payment method at 3
  and 1 days left, deduped through subscription metadata claimed before the
  send so concurrent replicas cannot double-send
2026-09-18 02:35:27 -06:00
Mauricio Siu 853ca33659 Merge pull request #5416 from Dokploy/fix/critical-next-rce-16.3.4
fix(deps): bump next to 16.3.4 to patch critical RCE advisories
2026-09-11 02:20:20 -06:00
Mauricio Siu a90c2a662f Merge pull request #5418 from EgerDev/feat/compose-models
feat(compose): support models in Compose specification
2026-09-11 02:20:07 -06:00
EgerDev 45dcdfc5be test(compose): tighten models types and regression tests 2026-09-11 00:11:40 -07:00
EgerDev 29cdc815db feat(compose): support AI models in Compose specification 2026-09-10 20:28:28 -07:00
Narciso E. Núñez Arias 9fa98de9c1 Merge pull request #5350 from RezaRahemtola/fix/ntfy-database-backup-newline
fix(notifications): add missing newline to ntfy database backup message
2026-09-10 19:00:08 -04:00
Dokploy Bot a4a1d15656 chore: sync hotfix from main into canary [skip ci] 2026-09-10 22:40:21 +00:00
Narciso E. Núñez Arias 326908869d Merge pull request #5358 from KingIronMan2011/patch-1
feat: added missing Railpack versions
2026-09-10 18:40:09 -04:00
Narciso E. Núñez Arias 18d07c7254 Merge pull request #5364 from maks-oleksyuk/chore/ci-update-actions
chore(ci): update GitHub Actions to latest versions and harden workflows
2026-09-10 18:32:42 -04:00
Narciso 4cd4b83523 fix(deps): bump next to 16.3.4 to patch critical RCE advisories
GHSA-2xp9-vwfh-vxw4 and GHSA-p293-qw3h-jr36
2026-09-10 18:23:38 -04:00
Narciso E. Núñez Arias f91b2e2ca6 Merge pull request #5414 from aspatari/fix/infisical-imported-secrets
fix(vault): read Infisical secrets pulled in through an import
2026-09-10 18:16:08 -04:00
Artur Spatari e70c880612 test(vault): lock the precedence between two imports
Review flagged that multi-import collisions were untested. The order the code
implements is the documented one — Infisical: "If two imports carry a secret
with the same name, the value from the bottom-most import wins" — and the
response lists imports in that order, so sequential assignment matches it.

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

vault.test.ts: 58 passed.

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

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

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

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

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

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

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

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

Verified: typecheck (server + app) clean, full vitest suite
(947 passed / 5 env-conditional skips, 0 failures), go build/vet/test
clean for apps/monitoring.
2026-09-03 17:51:40 -04:00
Mauricio Siu 5bc53bfbff feat(managed-server): update managed server functionality and database schema
- Refactored the managed server creation process to utilize UpCloud services, including the introduction of new utility functions for server management.
- Updated the managed server schema to include new fields such as `providerVmId`, `zone`, and `sshKeyId`, enhancing server management capabilities.
- Implemented validation to prevent deletion of SSH keys that are in use by managed servers.
- Introduced a new SQL migration to add the `sshKeyId` column and associated foreign key constraints to the `managed_server` table.
- Enhanced the user experience by grouping available zones by continent in the UI.
- Updated pricing display logic to reflect new pricing structure and improved formatting.
2026-05-22 16:19:18 -06:00
Mauricio Siu 549b124fcd feat(database): add managed_server table and associated constraints
- Introduced a new SQL migration file "0167_dizzy_solo.sql" to create the "managed_server" table with relevant columns and a custom ENUM type for server status.
- Added foreign key constraints linking "organizationId" to the "organization" table and "serverId" to the "server" table, enhancing data integrity.
- Updated journal and snapshot metadata to reflect the new migration.
2026-05-13 01:25:40 -06:00
Mauricio Siu 0c5da0b36f Merge branch 'canary' into feature/managed-servers 2026-05-13 01:25:23 -06:00
Mauricio Siu ee18724dd7 chore(database): remove obsolete migration files for managed servers
- Deleted the SQL migration file and associated journal entry for the "0166_lame_meltdown" migration, as it is no longer required.
- This cleanup contributes to a more organized migration history and minimizes potential confusion in future database management.
2026-05-13 01:25:05 -06:00
Mauricio Siu 1ae9b4025c chore(database): remove deprecated cultured captain cross migration files
- Deleted the SQL migration file and associated journal entry for the "0167_cultured_captain_cross" migration, as it is no longer needed.
- This cleanup helps maintain a tidy migration history and reduces potential confusion in future database management.
2026-05-13 01:24:24 -06:00
Mauricio Siu 5bc870dc2d fix(hostinger): add error handling to getHostingerDataCenters function
- Wrapped the API call in a try/catch block to log errors when fetching data centers.
- Reformatted the OFFERED_PLAN_IDS array for improved readability.

This update enhances the robustness of the Hostinger data center retrieval process.
2026-05-07 13:36:30 -06:00
Mauricio Siu 299950a323 feat(managed-servers): add managed servers functionality and API integration
- Introduced a new API for managing servers, including endpoints for listing, purchasing, and retrieving server plans.
- Added a new page and components for displaying managed servers in the dashboard.
- Updated the sidebar to include navigation for managed servers.
- Created database migrations for managed server types and status.
- Enhanced environment configuration with a new API key for Hostinger services.

This update enables users to manage their servers directly from the Dokploy dashboard, improving the overall user experience and functionality.
2026-05-05 08:10:30 -06:00
96 changed files with 32106 additions and 4572 deletions
+14 -14
View File
@@ -14,34 +14,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Get version from package.json
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
- name: Get latest GitHub tag
run: |
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
echo $LATEST_TAG
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
echo "$LATEST_TAG"
- name: Compare versions
run: |
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
if [ "$VERSION" != "$LATEST_TAG" ]; then
VERSION_CHANGED="true"
else
VERSION_CHANGED="false"
fi
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
echo "Comparing versions:"
echo "Current version: ${{ env.VERSION }}"
echo "Latest tag: ${{ env.LATEST_TAG }}"
echo "Current version: $VERSION"
echo "Latest tag: $LATEST_TAG"
echo "Version changed: $VERSION_CHANGED"
- name: Check if a PR already exists
run: |
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
@@ -56,12 +56,12 @@ jobs:
git push origin canary
gh pr create \
--title "🚀 Release ${{ env.VERSION }}" \
--body '
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
--title "🚀 Release $VERSION" \
--body "
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
### 🔍 Changes Include:
- Version bump to ${{ env.VERSION }}
- Version bump to $VERSION
- All changes from canary branch
### ✅ Pre-merge Checklist:
@@ -69,7 +69,7 @@ jobs:
- [ ] Documentation updated
- [ ] Docker images built and tested
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
--base main \
--head canary \
--label "release" --label "automated pr" || true \
+28 -19
View File
@@ -5,33 +5,38 @@ on:
branches: [main, canary]
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-push-cloud-image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set tag and version
id: meta-cloud
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT"
else
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.cloud
@@ -48,26 +53,28 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set tag and version
id: meta-schedule
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT"
else
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.schedule
@@ -80,26 +87,28 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set tag and version
id: meta-server
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT"
else
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.server
+60 -42
View File
@@ -8,18 +8,23 @@ on:
env:
IMAGE_NAME: dokploy/dokploy
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -27,15 +32,15 @@ jobs:
- name: Set tag and version
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
- name: Prepare env file
run: |
@@ -43,7 +48,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
@@ -53,13 +58,15 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -68,15 +75,15 @@ jobs:
id: meta
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
- name: Prepare env file
run: |
@@ -84,7 +91,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/arm64
@@ -96,20 +103,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
TAG="latest"
@@ -121,7 +130,7 @@ jobs:
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
@@ -138,32 +147,37 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.get_version.outputs.version }}
npm_version: ${{ steps.get_version.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Get version
id: get_version
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
- name: Fetch install.sh
env:
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
run: |
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }}
@@ -179,15 +193,19 @@ jobs:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
version: 10.22.0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 24.4.0
cache: pnpm
@@ -202,63 +220,63 @@ jobs:
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
echo "✅ MCP repo synced to version $NPM_VERSION"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
echo "✅ CLI repo synced to version $NPM_VERSION"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
git commit -m "chore: bump version to $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
echo "✅ SDK repo synced to version $NPM_VERSION"
+7 -2
View File
@@ -6,15 +6,20 @@ on:
pull_request:
branches: [canary]
permissions:
contents: read
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format --write
+4 -2
View File
@@ -16,17 +16,19 @@ jobs:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
- name: Cherry-pick fix to main
env:
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
SHA="${{ github.event.pull_request.merge_commit_sha }}"
SHA="$MERGE_SHA"
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA"
else
+5 -1
View File
@@ -7,12 +7,16 @@ concurrency:
group: hotfix-to-main
cancel-in-progress: false
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
+28 -19
View File
@@ -7,18 +7,23 @@ on:
env:
IMAGE_NAME: dokploy/monitoring
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -26,17 +31,17 @@ jobs:
- name: Set tag
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.monitoring
@@ -47,13 +52,15 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -61,17 +68,17 @@ jobs:
- name: Set
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.monitoring
@@ -84,27 +91,29 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
+5 -3
View File
@@ -14,9 +14,11 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24.4.0
cache: "pnpm"
+7 -4
View File
@@ -12,17 +12,20 @@ on:
workflow_dispatch:
permissions:
contents: read
jobs:
generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24.4.0
cache: "pnpm"
@@ -28,6 +28,9 @@ env:
DOKPLOY_SERVICE: dokploy
DOKPLOY_PORT: 3000
permissions:
contents: read
# ──────────────────────────────────────────────────────────────────────────────
jobs:
@@ -421,8 +424,10 @@ jobs:
# ── Job summary ────────────────────────────────────────────────────────
- name: Write job summary
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: |
STATUS="${{ job.status }}"
STATUS="$JOB_STATUS"
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
+8403
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,3 +1,6 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
PORT=3000
NODE_ENV=development
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
HOSTINGER_API_KEY=
@@ -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");
});
});
@@ -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",
},
});
});
+71
View File
@@ -472,6 +472,77 @@ describe("infisical client", () => {
expect(params.get("secretPath")).toBe("/frontend");
});
it("asks for imported secrets and merges them", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://imported" });
const [listUrl] = mockFetch.mock.calls[1] as [string];
// snake_case on purpose: `includeImports` is silently ignored and the
// response comes back with an empty `imports` array.
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
});
it("lets the later import win when two define the same key", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [],
imports: [
{
secretPath: "/base",
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
},
{
secretPath: "/override",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://override" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
// Infisical documents this order: "If two imports carry a secret with the
// same name, the value from the bottom-most import wins." The response
// lists imports in that order, so assigning them in sequence matches it.
expect(result).toEqual({ DB_URL: "postgres://override" });
});
it("lets a folder's own secret win over an imported one of the same name", async () => {
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
jsonResponse({
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
imports: [
{
secretPath: "/shared",
secrets: [
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
],
},
],
}),
);
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
expect(result).toEqual({ DB_URL: "postgres://local" });
});
it("throws a clear error for a missing secret", async () => {
mockFetch
.mockResolvedValueOnce(loginResponse())
@@ -0,0 +1,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");
});
});
@@ -22,6 +22,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
interface Props {
@@ -30,6 +31,7 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
pullImages: z.boolean(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -57,22 +59,25 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
pullImages: false,
},
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
if (data?.command) {
if (data) {
form.reset({
command: data?.command || "",
command: data.command || "",
pullImages: data.pullImages ?? false,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (data: AddCommand) => {
const onSubmit = async (formData: AddCommand) => {
await mutateAsync({
composeId,
command: data?.command,
command: formData.command,
pullImages: formData.pullImages,
})
.then(async () => {
toast.success("Command Updated");
@@ -109,6 +114,30 @@ export const AddCommandCompose = ({ composeId }: Props) => {
<strong>docker</strong>.
</AlertBlock>
<div className="flex flex-col gap-4">
{data?.composeType === "docker-compose" && (
<FormField
control={form.control}
name="pullImages"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
<div className="space-y-0.5">
<FormLabel>Pull latest images on deploy</FormLabel>
<FormDescription>
Adds <strong>--pull always</strong> to the default
command so every deploy fetches the newest image for
each tag. Has no effect when a custom command is set.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="command"
@@ -0,0 +1,216 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const freshVolumesSchema = z.object({
confirmName: z.string().min(1, {
message: "Compose name is required",
}),
});
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
interface Props {
composeId: string;
}
export const FreshVolumes = ({ composeId }: Props) => {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.compose.one.useQuery(
{ composeId },
{ enabled: !!composeId },
);
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
const form = useForm<FreshVolumesForm>({
defaultValues: { confirmName: "" },
resolver: zodResolver(freshVolumesSchema),
});
const expectedName = `${data?.name}/${data?.appName}`;
const isRunning = data?.composeStatus === "running";
const onSubmit = async (formData: FreshVolumesForm) => {
if (formData.confirmName !== expectedName) {
form.setError("confirmName", {
message: `Compose name must match "${expectedName}"`,
});
return;
}
await deploy({ composeId, freshVolumes: true })
.then(() => {
toast.success("Compose deployed with fresh volumes");
setIsOpen(false);
form.reset();
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
};
if (!canDeploy || data?.composeType !== "docker-compose") return null;
return (
<Card className="bg-background border-destructive/50">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h3 className="text-base font-semibold">
Deploy with Fresh Volumes
</h3>
<p className="text-sm text-muted-foreground">
Permanently wipes every volume of this compose and redeploys it
from a clean state. All persistent data (databases, uploads,
caches) will be lost.
</p>
</div>
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) form.reset();
}}
>
<DialogTrigger asChild>
<Button
isLoading={isPending}
variant="outline"
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
>
<HardDriveDownload className="mr-2 h-4 w-4" />
Deploy with Fresh Volumes
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current compose</li>
<li>Delete all volumes and their data</li>
<li>Redeploy the compose with empty volumes</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</div>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-fresh-volumes"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="confirmName"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
copy(expectedName);
toast.success(
"Copied to clipboard. Be careful!",
);
}
}}
>
{expectedName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input
placeholder="Enter compose name to confirm"
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{isRunning && (
<AlertBlock type="warning" className="w-full">
Cannot deploy with fresh volumes while a deployment is
running. Please wait for it to finish and then try again.
</AlertBlock>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
isLoading={isPending}
disabled={isRunning}
form="hook-form-fresh-volumes"
type="submit"
variant="destructive"
>
Wipe volumes and deploy
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
);
};
@@ -1,11 +1,4 @@
import {
Ban,
CheckCircle2,
HardDriveDownload,
RefreshCcw,
Rocket,
Terminal,
} from "lucide-react";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
@@ -89,51 +82,6 @@ export const ComposeActions = ({ composeId }: Props) => {
</Button>
</DialogAction>
)}
{canDeploy && data?.composeType === "docker-compose" && (
<DialogAction
title="Deploy with Fresh Volumes"
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
type="destructive"
onClick={async () => {
await deploy({
composeId: composeId,
freshVolumes: true,
})
.then(() => {
toast.success("Compose deployed with fresh volumes");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="outline"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<HardDriveDownload className="size-4 mr-1" />
Fresh Volumes
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Deploy with fresh volumes (removes all persistent data)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Rebuild Compose"
@@ -114,7 +114,7 @@ export const ShowContainers = ({ serverId }: Props) => {
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by name..."
@@ -189,7 +189,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<>
<div className="flex flex-col gap-6 w-full ">
<div className="mt-6 grid gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by hostname..."
@@ -24,7 +24,7 @@ export const AiForm = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between">
<div>
@@ -62,7 +62,7 @@ export const AiForm = () => {
key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div>
<span className="text-sm font-medium">
{config.name}
@@ -22,7 +22,7 @@ export const ShowApiKeys = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -1,4 +1,4 @@
import { CreditCard, FileText } from "lucide-react";
import { CreditCard, FileText, Server } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import {
@@ -17,6 +17,11 @@ const navigationItems = [
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
@@ -29,7 +34,7 @@ export const ShowBillingInvoices = () => {
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -10,6 +10,7 @@ import {
Loader2,
MinusIcon,
PlusIcon,
Server,
ShieldCheck,
} from "lucide-react";
import Link from "next/link";
@@ -83,6 +84,11 @@ const navigationItems = [
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
@@ -111,14 +117,20 @@ export const ShowBilling = () => {
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
const [switchingTrial, setSwitchingTrial] = useState(false);
const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => {
const planName = tier === "startup" ? "Startup" : "Hobby";
const handleSwitchTrialPlan = async (
tier: "hobby" | "startup",
isAnnualPlan: boolean,
label?: string,
) => {
const planName =
label ??
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
const switchPlan = async () => {
await upgradeSubscription({
tier,
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
isAnnual: false,
isAnnual: isAnnualPlan,
});
await new Promise((resolve) => setTimeout(resolve, 3000));
await Promise.all([
@@ -133,7 +145,7 @@ export const ShowBilling = () => {
await toast
.promise(switchPlan(), {
loading: `Switching to ${planName}...`,
success: `Your trial is now on the ${planName} plan`,
success: `Your trial is now on ${planName}`,
error: (error) =>
error instanceof Error ? error.message : "Error switching plan",
})
@@ -336,6 +348,11 @@ export const ShowBilling = () => {
<Badge className="capitalize" variant="secondary">
{billingStatus.plan ?? "Trial"}
</Badge>
{billingStatus.plan && (
<Badge variant="outline">
{billingStatus.isAnnual ? "Annual" : "Monthly"}
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground">
{billingStatus.isOnTrial
@@ -356,23 +373,45 @@ export const ShowBilling = () => {
<div className="flex flex-wrap items-center gap-2">
{billingStatus.isOnTrial &&
useNewPricing &&
billingStatus.plan && (
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan === "startup"
? "hobby"
: "startup",
)
}
>
{billingStatus.plan === "startup"
? "Switch to Hobby"
: "Switch to Startup"}
</Button>
billingStatus.plan &&
billingStatus.plan !== "legacy" && (
<>
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan === "startup"
? "hobby"
: "startup",
billingStatus.isAnnual,
)
}
>
{billingStatus.plan === "startup"
? "Switch to Hobby"
: "Switch to Startup"}
</Button>
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan as "hobby" | "startup",
!billingStatus.isAnnual,
billingStatus.isAnnual
? "monthly billing"
: "annual billing",
)
}
>
{billingStatus.isAnnual
? "Switch to monthly"
: "Switch to annual"}
</Button>
</>
)}
<Button
size="sm"
@@ -0,0 +1,502 @@
import {
AlertCircle,
CheckCircle2,
Clock,
CreditCard,
ExternalLink,
FileText,
Loader2,
Plus,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
const STATUS_MAP: Record<
string,
{
label: string;
icon: React.ReactNode;
variant: "default" | "secondary" | "destructive" | "outline";
}
> = {
pending: {
label: "Pending",
icon: <Clock className="size-3" />,
variant: "secondary",
},
provisioning: {
label: "Provisioning",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
configuring: {
label: "Installing Dokploy",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
ready: {
label: "Ready",
icon: <CheckCircle2 className="size-3" />,
variant: "default",
},
error: {
label: "Error",
icon: <XCircle className="size-3" />,
variant: "destructive",
},
terminating: {
label: "Terminating",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
terminated: {
label: "Terminated",
icon: <AlertCircle className="size-3" />,
variant: "outline",
},
};
function formatSpecs(cpus: number, memoryGb: number, storageGb: number) {
return `${cpus} vCPU · ${memoryGb} GB RAM · ${storageGb} GB NVMe`;
}
function formatPrice(priceCents: number) {
return `$${(priceCents / 100).toFixed(2)}/mo`;
}
const CONTINENT_ORDER = ["Europe", "Americas", "Asia-Pacific"];
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
const [open, setOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>("");
const [selectedZone, setSelectedZone] = useState<string>("");
const { data: plans, isLoading: loadingPlans } =
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
const { data: zones, isLoading: loadingZones } =
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
const isLoadingOptions = loadingPlans || loadingZones;
const purchase = api.managedServer.purchase.useMutation({
onSuccess: () => {
toast.success("Server order placed! Provisioning will take ~5 minutes.");
setOpen(false);
onSuccess();
},
onError: (err) => {
toast.error(err.message);
},
});
const plan = plans?.find((p) => p.id === selectedPlan);
const groupedZones = zones?.reduce<Record<string, typeof zones>>((acc, z) => {
if (!acc[z.continent]) acc[z.continent] = [];
acc[z.continent]!.push(z);
return acc;
}, {});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="size-4 mr-2" />
Order Server
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Order a Managed Server</DialogTitle>
<DialogDescription>
We'll provision and configure a server for you automatically. Ready
in ~5 minutes.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{isLoadingOptions ? (
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<p className="text-sm">Loading available plans...</p>
</div>
) : (
<div className="space-y-5">
{/* Plan cards */}
<div className="space-y-2">
<Label>Plan</Label>
<div className="grid grid-cols-2 gap-2">
{plans?.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedPlan(p.id)}
className={cn(
"flex flex-col gap-1 rounded-lg border p-3 text-left transition-colors",
selectedPlan === p.id
? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground",
)}
>
<div className="flex items-center justify-between">
<p className="font-semibold text-sm">{p.name}</p>
<p className="text-sm font-medium text-primary">
{formatPrice(p.priceCents)}
</p>
</div>
<p className="text-xs text-muted-foreground">
{formatSpecs(p.cpus, p.memoryGb, p.storageGb)}
</p>
</button>
))}
</div>
</div>
{/* Zone selector grouped by continent */}
<div className="space-y-2">
<Label>Location</Label>
<Select value={selectedZone} onValueChange={setSelectedZone}>
<SelectTrigger>
<SelectValue placeholder="Select a location..." />
</SelectTrigger>
<SelectContent
position="popper"
side="bottom"
sideOffset={4}
className="max-h-64 overflow-y-auto"
>
{CONTINENT_ORDER.filter((c) => groupedZones?.[c]).map((continent) => (
<div key={continent}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{continent}
</div>
{groupedZones?.[continent]?.map((z) => (
<SelectItem key={z.id} value={z.id}>
{z.description}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
{plan && selectedZone && (
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<span className="font-medium">{plan.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Specs</span>
<span className="font-medium">
{formatSpecs(plan.cpus, plan.memoryGb, plan.storageGb)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Price</span>
<span className="font-medium">{formatPrice(plan.priceCents)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Zone</span>
<span className="font-medium">
{zones?.find((z) => z.id === selectedZone)?.description ?? selectedZone}
</span>
</div>
</div>
)}
<Button
className="w-full"
disabled={!selectedPlan || !selectedZone || purchase.isPending}
onClick={() => {
if (!selectedPlan || !selectedZone) return;
purchase.mutate({
plan: selectedPlan,
zone: selectedZone,
});
}}
>
{purchase.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Placing order...
</>
) : (
"Order Server"
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
const PLAN_LABELS: Record<string, string> = {
hobby: "Hobby",
starter: "Starter",
pro: "Pro",
business: "Business",
};
export const ShowManagedServers = () => {
const router = useRouter();
const utils = api.useUtils();
const { data: servers, isLoading } = api.managedServer.list.useQuery();
const syncStatus = api.managedServer.syncStatus.useMutation({
onSuccess: () => utils.managedServer.list.invalidate(),
});
const reconnect = api.managedServer.reconnect.useMutation({
onSuccess: () => {
toast.success("Server reconnected successfully.");
utils.managedServer.list.invalidate();
},
onError: (err) => toast.error(err.message),
});
const deleteServer = api.managedServer.delete.useMutation({
onSuccess: () => {
toast.success("Server terminated.");
utils.managedServer.list.invalidate();
},
onError: (err) => toast.error(err.message),
});
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Server className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and servers
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
)}
>
<Icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-base">Managed Servers</h3>
<p className="text-sm text-muted-foreground">
Servers provisioned and managed by Dokploy Cloud
</p>
</div>
<OrderServerDialog
onSuccess={() => utils.managedServer.list.invalidate()}
/>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : servers?.length === 0 ? (
<div className="text-center py-12 border rounded-lg border-dashed">
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
<p className="text-sm font-medium">No managed servers yet</p>
<p className="text-xs text-muted-foreground mt-1">
Order a server and we'll provision and configure it for you
automatically.
</p>
</div>
) : (
<div className="space-y-3">
{servers?.map((s) => {
const status =
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
const isProvisioning = [
"pending",
"provisioning",
"configuring",
].includes(s.status);
return (
<div
key={s.managedServerId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-3">
<Server className="size-5 text-muted-foreground shrink-0" />
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{PLAN_LABELS[s.plan] ?? s.plan}
</span>
<Badge
variant={status?.variant}
className="flex items-center gap-1 text-xs h-5"
>
{status?.icon}
{status?.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{s.hostname ?? ""}
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
{s.zone ? ` · ${s.zone}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isProvisioning && (
<Button
variant="ghost"
size="sm"
onClick={() =>
syncStatus.mutate({
managedServerId: s.managedServerId,
})
}
disabled={syncStatus.isPending}
>
<Loader2
className={cn(
"size-4",
syncStatus.isPending && "animate-spin",
)}
/>
</Button>
)}
{s.status === "ready" && !s.serverId && (
<Button
variant="outline"
size="sm"
onClick={() =>
reconnect.mutate({
managedServerId: s.managedServerId,
})
}
disabled={reconnect.isPending}
>
{reconnect.isPending ? (
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1.5" />
)}
Reconnect
</Button>
)}
{s.status === "ready" && s.server && (
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/settings/server?serverId=${s.serverId}`}
>
<ExternalLink className="size-3.5 mr-1.5" />
Open
</Link>
</Button>
)}
<DialogAction
title="Terminate Server"
description="This will permanently destroy the server and all data on it. This action cannot be undone."
type="destructive"
onClick={() =>
deleteServer.mutate({
managedServerId: s.managedServerId,
})
}
>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
</div>
);
})}
</div>
)}
</div>
</CardContent>
</div>
</Card>
</div>
);
};
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -111,7 +111,7 @@ export const ShowCertificates = () => {
key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -20,7 +20,7 @@ export const ShowRegistry = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -55,7 +55,7 @@ export const ShowRegistry = () => {
key={registry.registryId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -19,7 +19,7 @@ export const ShowDestinations = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
key={destination.destinationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col gap-1">
<span className="text-sm">
{index + 1}. {destination.name}
@@ -35,7 +35,7 @@ export const ShowDnsProviders = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full max-w-5xl mx-auto">
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -52,7 +52,7 @@ export const ShowDnsZones = ({ dnsProviderId }: Props) => {
api.dnsProvider.listZones.useQuery({ dnsProviderId });
return (
<div className="w-full max-w-5xl mx-auto">
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -69,7 +69,7 @@ export const ShowGitProviders = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -31,7 +31,7 @@ export const ShowNotifications = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -70,7 +70,7 @@ export const ShowNotifications = () => {
key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg">
@@ -150,7 +150,7 @@ export const ProfileForm = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -49,7 +49,7 @@ export const ShowServers = () => {
return (
<div className="w-full">
{query?.success && isCloud && <WelcomeSubscription />}
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -21,7 +21,7 @@ export const ShowDestinations = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -57,7 +57,7 @@ export const ShowDestinations = () => {
key={sshKey.sshKeyId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
@@ -22,7 +22,7 @@ export const TagManager = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -320,7 +320,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
{isCustomRole && (
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground">
@@ -40,7 +40,7 @@ export const ShowInvitations = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -52,7 +52,7 @@ export const ShowUsers = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -33,7 +33,7 @@ export const ShowVaultProviders = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -123,7 +123,7 @@ export const WebDomain = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div className="flex flex-col gap-1">
@@ -24,7 +24,7 @@ export const WebServer = () => {
return (
<div className="w-full">
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -0,0 +1,245 @@
import type { ServiceType } from "@dokploy/server/db/schema";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { ArrowRightLeft } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { type LogLine, parseLogs } from "../docker/logs/utils";
const LOCAL_SERVER = "dokploy";
const transferSchema = z.object({
targetServerId: z.string().min(1, { message: "Select a target server" }),
removeSourceData: z.boolean(),
});
type TransferForm = z.infer<typeof transferSchema>;
interface Props {
id: string;
type: ServiceType;
serverId?: string | null;
}
export const TransferService = ({ id, type, serverId }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const [isOpen, setIsOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [logs, setLogs] = useState<LogLine[]>([]);
const [request, setRequest] = useState<{
targetServerId: string | null;
removeSourceData: boolean;
} | null>(null);
const targets = [
...(!isCloud && serverId
? [{ serverId: LOCAL_SERVER, name: "Dokploy Server" }]
: []),
...(servers ?? []).filter((server) => server.serverId !== serverId),
];
const form = useForm<TransferForm>({
defaultValues: { targetServerId: "", removeSourceData: false },
resolver: zodResolver(transferSchema),
});
api.transfer.start.useSubscription(
{
serviceType: type,
serviceId: id,
targetServerId: request?.targetServerId ?? null,
removeSourceData: request?.removeSourceData ?? false,
},
{
enabled: isTransferring && request !== null,
onData(line) {
setLogs((prev) => [...prev, ...parseLogs(line)]);
if (line.startsWith("Transfer completed")) {
setIsTransferring(false);
toast.success("Service transferred successfully");
utils.invalidate();
} else if (line.startsWith("Transfer failed")) {
setIsTransferring(false);
toast.error("Transfer failed, check the logs");
utils.invalidate();
}
},
onError(error) {
setIsTransferring(false);
toast.error(error.message);
},
},
);
const onSubmit = (values: TransferForm) => {
setRequest({
targetServerId:
values.targetServerId === LOCAL_SERVER ? null : values.targetServerId,
removeSourceData: values.removeSourceData,
});
setLogs([]);
setIsDrawerOpen(true);
setIsTransferring(true);
setIsOpen(false);
};
return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
isLoading={isTransferring}
>
<ArrowRightLeft className="size-4 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Transfer to another server</DialogTitle>
<DialogDescription>
Moves this service with its volumes, bind mounts, file mounts and
configuration to the selected server, then deploys it there.
</DialogDescription>
</DialogHeader>
{targets.length === 0 ? (
<AlertBlock type="info">
There are no other servers available to transfer this service to.
</AlertBlock>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-transfer-service"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="targetServerId"
render={({ field }) => (
<FormItem>
<FormLabel>Target server</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{targets.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="removeSourceData"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="ml-2">
Remove volumes from the source server after the
transfer
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
<AlertBlock type="warning">
<ul className="list-disc pl-4 space-y-1">
<li>The service is stopped while its data is copied.</li>
<li>
Point your DNS records to the new server, certificates are
issued again there.
</li>
<li>
Networks that only exist on the current server are
detached.
</li>
<li>
Bind mount host paths are copied but never deleted from
the source server.
</li>
</ul>
</AlertBlock>
</form>
</Form>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
{targets.length > 0 && (
<Button
isLoading={isTransferring}
form="hook-form-transfer-service"
type="submit"
>
Transfer
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
filteredLogs={logs}
/>
</>
);
};
@@ -605,7 +605,7 @@ type CreateRoleSchema = z.infer<typeof createRoleSchema>;
export const ManageCustomRoles = () => {
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -30,6 +30,7 @@ const dockerComposeServices = [
{ label: "networks", type: "keyword", info: "Define networks" },
{ label: "configs", type: "keyword", info: "Define configuration files" },
{ label: "secrets", type: "keyword", info: "Define secrets" },
{ label: "models", type: "keyword", info: "Define AI models" },
].map((opt) => ({
...opt,
apply: (
@@ -83,6 +84,11 @@ const dockerComposeServiceOptions = [
},
{ label: "restart", type: "keyword", info: "Restart policy" },
{ label: "networks", type: "keyword", info: "Networks to join" },
{
label: "models",
type: "keyword",
info: "AI models to use, referencing top-level models",
},
].map((opt) => ({
...opt,
apply: (
@@ -35,6 +35,16 @@
"additionalProperties": false
},
"models": {
"id": "#/properties/models",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/model"
}
}
},
"networks": {
"id": "#/properties/networks",
"type": "object",
@@ -335,6 +345,30 @@
"mem_swappiness": { "type": ["integer", "string"] },
"memswap_limit": { "type": ["number", "string"] },
"network_mode": { "type": "string" },
"models": {
"oneOf": [
{ "$ref": "#/definitions/list_of_strings" },
{
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"oneOf": [
{
"type": "object",
"properties": {
"endpoint_var": { "type": "string" },
"model_var": { "type": "string" }
},
"additionalProperties": false,
"patternProperties": { "^x-": {} }
},
{ "type": "null" }
]
}
}
}
]
},
"networks": {
"oneOf": [
{ "$ref": "#/definitions/list_of_strings" },
@@ -890,6 +924,23 @@
"patternProperties": { "^x-": {} }
},
"model": {
"id": "#/definitions/model",
"type": "object",
"properties": {
"name": { "type": "string" },
"model": { "type": "string" },
"context_size": { "type": "integer" },
"runtime_flags": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["model"],
"additionalProperties": false,
"patternProperties": { "^x-": {} }
},
"command": {
"oneOf": [
{ "type": "null" },
@@ -0,0 +1 @@
ALTER TABLE "compose" ADD COLUMN "pullImages" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,22 @@
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
CREATE TABLE "managed_server" (
"managedServerId" text PRIMARY KEY NOT NULL,
"organizationId" text NOT NULL,
"serverId" text,
"plan" text NOT NULL,
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
"providerVmId" text,
"zone" text NOT NULL,
"sshKeyId" text,
"ipAddress" text,
"hostname" text,
"stripeSubscriptionId" text,
"stripePriceId" text,
"errorMessage" text,
"createdAt" text NOT NULL,
"updatedAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_sshKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("sshKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -1373,6 +1373,20 @@
"when": 1788867370653,
"tag": "0195_classy_whirlwind",
"breakpoints": true
},
{
"idx": 196,
"version": "7",
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1790609715152,
"tag": "0197_aberrant_psynapse",
"breakpoints": true
}
]
}
+3 -3
View File
@@ -109,7 +109,7 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "16.3.0",
"next": "16.3.4",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
@@ -133,7 +133,7 @@
"resend": "^6.0.2",
"semver": "7.7.3",
"shadcn": "^4.11.0",
"shell-quote": "^1.8.1",
"shell-quote": "^1.8.4",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "~1.16.0",
@@ -180,7 +180,7 @@
"tsx": "^4.22.4",
"typescript": "^7.0.2",
"vite-tsconfig-paths": "4.3.2",
"vitest": "^4.0.18"
"vitest": "^4.1.0"
},
"ct3aMetadata": {
"initVersion": "7.25.2"
@@ -36,6 +36,7 @@ import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -194,6 +195,13 @@ const Service = (
{permissions?.service.create && (
<UpdateApplication applicationId={applicationId} />
)}
{permissions?.service.create && (
<TransferService
id={applicationId}
type="application"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={applicationId} type="application" />
)}
@@ -23,6 +23,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { FreshVolumes } from "@/components/dashboard/compose/advanced/fresh-volumes";
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
@@ -33,6 +34,7 @@ import { ShowBackups } from "@/components/dashboard/database/backups/show-backup
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -184,6 +186,13 @@ const Service = (
<UpdateCompose composeId={composeId} />
)}
{permissions?.service.create && (
<TransferService
id={composeId}
type="compose"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={composeId} type="compose" />
)}
@@ -434,6 +443,7 @@ const Service = (
<ShowImport composeId={composeId} />
<AssignComposeNetworks composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} />
<FreshVolumes composeId={composeId} />
</div>
</TabsContent>
)}
@@ -23,6 +23,7 @@ import { UpdateLibsql } from "@/components/dashboard/libsql/update-libsql";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { LibsqlIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -145,6 +146,13 @@ const Libsql = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateLibsql libsqlId={libsqlId} />
{(auth?.role === "owner" || auth?.canCreateServices) && (
<TransferService
id={libsqlId}
type="libsql"
serverId={data?.serverId}
/>
)}
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={libsqlId} type="libsql" />
)}
@@ -23,6 +23,7 @@ import { UpdateMariadb } from "@/components/dashboard/mariadb/update-mariadb";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { MariadbIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -159,6 +160,13 @@ const Mariadb = (
{permissions?.service.create && (
<UpdateMariadb mariadbId={mariadbId} />
)}
{permissions?.service.create && (
<TransferService
id={mariadbId}
type="mariadb"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={mariadbId} type="mariadb" />
)}
@@ -23,6 +23,7 @@ import { UpdateMongo } from "@/components/dashboard/mongo/update-mongo";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { MongodbIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -159,6 +160,13 @@ const Mongo = (
{permissions?.service.create && (
<UpdateMongo mongoId={mongoId} />
)}
{permissions?.service.create && (
<TransferService
id={mongoId}
type="mongo"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={mongoId} type="mongo" />
)}
@@ -23,6 +23,7 @@ import { ShowGeneralMysql } from "@/components/dashboard/mysql/general/show-gene
import { ShowInternalMysqlCredentials } from "@/components/dashboard/mysql/general/show-internal-mysql-credentials";
import { UpdateMysql } from "@/components/dashboard/mysql/update-mysql";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { MysqlIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -159,6 +160,13 @@ const MySql = (
{permissions?.service.create && (
<UpdateMysql mysqlId={mysqlId} />
)}
{permissions?.service.create && (
<TransferService
id={mysqlId}
type="mysql"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={mysqlId} type="mysql" />
)}
@@ -23,6 +23,7 @@ import { ShowGeneralPostgres } from "@/components/dashboard/postgres/general/sho
import { ShowInternalPostgresCredentials } from "@/components/dashboard/postgres/general/show-internal-postgres-credentials";
import { UpdatePostgres } from "@/components/dashboard/postgres/update-postgres";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { PostgresqlIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -158,6 +159,13 @@ const Postgresql = (
{permissions?.service.create && (
<UpdatePostgres postgresId={postgresId} />
)}
{permissions?.service.create && (
<TransferService
id={postgresId}
type="postgres"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={postgresId} type="postgres" />
)}
@@ -22,6 +22,7 @@ import { ShowGeneralRedis } from "@/components/dashboard/redis/general/show-gene
import { ShowInternalRedisCredentials } from "@/components/dashboard/redis/general/show-internal-redis-credentials";
import { UpdateRedis } from "@/components/dashboard/redis/update-redis";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { RedisIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -158,6 +159,13 @@ const Redis = (
{permissions?.service.create && (
<UpdateRedis redisId={redisId} />
)}
{permissions?.service.create && (
<TransferService
id={redisId}
type="redis"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={redisId} type="redis" />
)}
@@ -21,7 +21,7 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
@@ -11,7 +11,7 @@ import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
@@ -0,0 +1,39 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
const Page = () => {
return <ShowManagedServers />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext,
) {
if (!IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user } = await validateRequest(ctx.req);
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
return { props: {} };
}
@@ -16,7 +16,7 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<ProfileForm />
{isCloud && <LinkingAccount />}
{permissions?.api.read && <ShowApiKeys />}
@@ -15,7 +15,7 @@ const Page = () => {
const { data: user } = api.user.get.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<WebDomain />
<WebServer />
<div className="w-full flex flex-col gap-4">
@@ -25,7 +25,7 @@ interface Props {
const Page = ({ isCloud }: Props) => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
@@ -12,7 +12,7 @@ import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
+1
View File
@@ -498,6 +498,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
// Prefetch the public branding so the login/onboarding logo and app name
// render correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
await helpers.sso.showSignInWithSSO.prefetch();
if (IS_CLOUD) {
try {
+4
View File
@@ -38,6 +38,7 @@ import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { managedServerRouter } from "./routers/proprietary/managed-server";
import { scimRouter } from "./routers/proprietary/scim";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
@@ -53,6 +54,7 @@ import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { tagRouter } from "./routers/tag";
import { transferRouter } from "./routers/transfer";
import { userRouter } from "./routers/user";
import { vaultProviderRouter } from "./routers/vault-provider";
import { volumeBackupsRouter } from "./routers/volume-backups";
@@ -120,6 +122,8 @@ export const appRouter = createTRPCRouter({
tag: tagRouter,
patch: patchRouter,
overview: overviewRouter,
transfer: transferRouter,
managedServer: managedServerRouter,
});
// export type definition of API
@@ -0,0 +1,333 @@
import {
createServer,
IS_CLOUD,
serverSetup,
} from "@dokploy/server";
import {
apiCreateManagedServer,
apiDeleteManagedServer,
apiFindOneManagedServer,
} from "@dokploy/server/db/schema/managed-server";
import {
createManagedServer,
deleteManagedServer,
findManagedServerById,
findManagedServersByOrg,
updateManagedServer,
} from "@dokploy/server/services/managed-server";
import { createSshKey } from "@dokploy/server/services/ssh-key";
import { generateSSHKey } from "@dokploy/server/utils/filesystem/ssh";
import {
DOKPLOY_PLANS,
createUpCloudServer,
deleteUpCloudServer,
getPublicIPv4,
getUpCloudServer,
getUpCloudZones,
stopUpCloudServer,
} from "@dokploy/server/utils/upcloud";
import { TRPCError } from "@trpc/server";
import { nanoid } from "nanoid";
import { adminProcedure, createTRPCRouter } from "../../trpc";
export const managedServerRouter = createTRPCRouter({
getPlans: adminProcedure.query(async () => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Managed servers are only available in Dokploy Cloud",
});
}
return DOKPLOY_PLANS;
}),
getDataCenters: adminProcedure.query(async () => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Managed servers are only available in Dokploy Cloud",
});
}
return getUpCloudZones();
}),
list: adminProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) return [];
return findManagedServersByOrg(ctx.session.activeOrganizationId);
}),
one: adminProcedure
.input(apiFindOneManagedServer)
.query(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return record;
}),
purchase: adminProcedure
.input(apiCreateManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Managed servers are only available in Dokploy Cloud",
});
}
const plan = DOKPLOY_PLANS.find((p) => p.id === input.plan);
if (!plan) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan" });
}
const hostname =
`dokploy-${ctx.session.activeOrganizationId.slice(0, 8)}-${nanoid(6)}`.toLowerCase();
const managedRecord = await createManagedServer({
organizationId: ctx.session.activeOrganizationId,
plan: input.plan,
zone: input.zone,
status: "provisioning",
});
provisionManagedServer(
managedRecord.managedServerId,
plan.upcloudPlan,
input.zone,
hostname,
ctx.session.activeOrganizationId,
).catch(async (err) => {
const responseBody = err?.response?.data;
const detail =
responseBody?.message ??
responseBody?.error ??
(typeof responseBody === "string" ? responseBody : null);
const errorMessage = detail
? `${err?.message}: ${typeof detail === "object" ? JSON.stringify(detail) : detail}`
: (err?.message ?? "Unknown error during provisioning");
console.error("[managed-server] provisioning failed:", errorMessage, responseBody);
await updateManagedServer(managedRecord.managedServerId, {
status: "error",
errorMessage,
});
});
return managedRecord;
}),
delete: adminProcedure
.input(apiDeleteManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
await updateManagedServer(input.managedServerId, {
status: "terminating",
});
const upcloudUuid = record.providerVmId;
terminateManagedServer(input.managedServerId, upcloudUuid ?? null).catch(
(err) => console.error("[managed-server] termination failed:", err),
);
return { ok: true };
}),
reconnect: adminProcedure
.input(apiFindOneManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (record.serverId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Server is already linked",
});
}
if (!record.ipAddress) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No IP address available to reconnect",
});
}
if (!record.sshKeyId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No SSH key found for this managed server",
});
}
const name = record.hostname ?? record.managedServerId;
const serverRecord = await createServer(
{
name: `Managed • ${name}`,
description: "Managed server provisioned by Dokploy Cloud",
ipAddress: record.ipAddress,
port: 22,
username: "root",
serverType: "deploy",
enableDockerCleanup: true,
sshKeyId: record.sshKeyId,
},
ctx.session.activeOrganizationId,
);
await updateManagedServer(input.managedServerId, {
serverId: serverRecord.serverId,
status: "ready",
});
return findManagedServerById(input.managedServerId);
}),
syncStatus: adminProcedure
.input(apiFindOneManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const upcloudUuid = record.providerVmId;
if (!upcloudUuid) return record;
const vm = await getUpCloudServer(upcloudUuid);
const ipAddress = getPublicIPv4(vm) ?? record.ipAddress ?? undefined;
await updateManagedServer(input.managedServerId, {
ipAddress,
hostname: vm.hostname ?? undefined,
status:
vm.state === "started"
? record.serverId
? "ready"
: "configuring"
: record.status,
});
return findManagedServerById(input.managedServerId);
}),
});
async function provisionManagedServer(
managedServerId: string,
upcloudPlan: string,
zone: string,
hostname: string,
organizationId: string,
) {
const { publicKey, privateKey } = await generateSSHKey("rsa");
const vm = await createUpCloudServer({
hostname,
upcloudPlan,
zone,
sshKey: publicKey,
});
await updateManagedServer(managedServerId, {
providerVmId: vm.uuid,
hostname: vm.hostname,
status: "configuring",
});
await waitForServerStarted(vm.uuid, managedServerId);
const finalVm = await getUpCloudServer(vm.uuid);
const finalIp = getPublicIPv4(finalVm);
if (!finalIp) {
throw new Error("VM is started but has no public IPv4 address");
}
const sshKey = await createSshKey({
name: hostname,
publicKey,
privateKey,
organizationId,
});
if (!sshKey) throw new Error("Failed to create SSH key");
const serverRecord = await createServer(
{
name: `Managed • ${hostname}`,
description: "Managed server provisioned by Dokploy Cloud",
ipAddress: finalIp,
port: 22,
username: "root",
serverType: "deploy",
enableDockerCleanup: true,
sshKeyId: sshKey.sshKeyId,
},
organizationId,
);
await updateManagedServer(managedServerId, {
serverId: serverRecord.serverId,
sshKeyId: sshKey.sshKeyId,
ipAddress: finalIp,
});
await serverSetup(serverRecord.serverId);
await updateManagedServer(managedServerId, { status: "ready" });
}
async function waitForServerStarted(
uuid: string,
_managedServerId: string,
maxAttempts = 30,
intervalMs = 10_000,
) {
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, intervalMs));
const vm = await getUpCloudServer(uuid);
if (vm.state === "started") return;
if (vm.state === "error") throw new Error("VM entered error state");
}
throw new Error("Timed out waiting for VM to start");
}
async function waitForServerStopped(
uuid: string,
maxAttempts = 18,
intervalMs = 10_000,
) {
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, intervalMs));
const vm = await getUpCloudServer(uuid);
if (vm.state === "stopped") return;
}
}
async function terminateManagedServer(
managedServerId: string,
upcloudUuid: string | null,
) {
if (upcloudUuid) {
await stopUpCloudServer(upcloudUuid);
await waitForServerStopped(upcloudUuid);
await deleteUpCloudServer(upcloudUuid);
}
await deleteManagedServer(managedServerId);
}
@@ -6,6 +6,7 @@ import {
updateSSHKeyById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { managedServer } from "@dokploy/server/db/schema/managed-server";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import {
@@ -57,6 +58,16 @@ export const sshRouter = createTRPCRouter({
});
}
const linkedManagedServer = await db.query.managedServer.findFirst({
where: eq(managedServer.sshKeyId, input.sshKeyId),
});
if (linkedManagedServer) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This SSH key is used by a managed server and cannot be deleted.",
});
}
await audit(ctx, {
action: "delete",
resourceType: "sshKey",
@@ -388,6 +388,9 @@ export const stripeRouter = createTRPCRouter({
await stripe.subscriptions.update(owner.stripeSubscriptionId, {
items: updateItems,
proration_behavior: isTrialing ? "none" : "create_prorations",
...(isTrialing && subscription.trial_end
? { trial_end: subscription.trial_end }
: {}),
});
return { ok: true };
@@ -0,0 +1,87 @@
import {
getAccessibleServerIds,
IS_CLOUD,
transferService,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { apiTransferService } from "@/server/db/schema";
export const transferRouter = createTRPCRouter({
start: protectedProcedure
.meta({
openapi: {
path: "/transfer/start",
method: "POST",
override: true,
enabled: false,
},
})
.input(apiTransferService)
.subscription(async function* ({ input, ctx, signal }) {
await checkServicePermissionAndAccess(ctx, input.serviceId, {
service: ["create"],
deployment: ["create"],
});
if (input.targetServerId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.targetServerId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to the target server",
});
}
} else if (IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The Dokploy server is not available as a target",
});
}
const queue: string[] = [];
let done = false;
const log = (line: string) => queue.push(line);
const run = async () => {
const result = await transferService(
{
...input,
organizationId: ctx.session.activeOrganizationId,
},
log,
);
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: input.serviceId,
resourceName: result.appName,
metadata: { targetServerId: input.targetServerId },
});
};
run()
.then(() => log("Transfer completed successfully!"))
.catch((error) => {
log(
`Transfer failed ❌ ${error instanceof Error ? error.message : String(error)}`,
);
})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
});
+7
View File
@@ -92,6 +92,7 @@ export interface BillingStatus {
hasUsedTrial: boolean;
hasActiveAccess: boolean;
hasPaymentMethod: boolean;
isAnnual: boolean;
}
export const getBillingStatus = async (
@@ -106,6 +107,7 @@ export const getBillingStatus = async (
hasUsedTrial: false,
hasActiveAccess: true,
hasPaymentMethod: true,
isAnnual: false,
};
}
@@ -119,6 +121,7 @@ export const getBillingStatus = async (
hasUsedTrial: false,
hasActiveAccess: false,
hasPaymentMethod: false,
isAnnual: false,
};
}
@@ -146,6 +149,9 @@ export const getBillingStatus = async (
const hasPaymentMethod =
!!currentSub?.default_payment_method ||
!!customer?.invoice_settings?.default_payment_method;
const isAnnual =
(currentSub?.items.data[0]?.price as Stripe.Price | undefined)?.recurring
?.interval === "year";
const trialEndsAt = trialingSub?.trial_end
? new Date(trialingSub.trial_end * 1000)
: null;
@@ -164,5 +170,6 @@ export const getBillingStatus = async (
hasPaymentMethod,
hasUsedTrial: subscriptions.data.length > 0,
hasActiveAccess: plan !== null || !!trialingSub,
isAnnual,
};
};
+2 -2
View File
@@ -3,9 +3,10 @@ module github.com/mauriciogm/dokploy/apps/monitoring
go 1.20
require (
github.com/gofiber/fiber/v2 v2.52.6
github.com/gofiber/fiber/v2 v2.52.11
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v3 v3.24.5
)
@@ -20,7 +21,6 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect
+2 -4
View File
@@ -3,8 +3,8 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -49,8 +49,6 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0=
github.com/zcalusic/sysinfo v1.1.3/go.mod h1:NX+qYnWGtJVPV0yWldff9uppNKU4h40hJIRPf/pGLv4=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+3 -1
View File
@@ -48,7 +48,9 @@
"overrides": {
"esbuild": "0.20.2",
"better-call": "1.3.7",
"@better-fetch/fetch": "1.3.1"
"@better-fetch/fetch": "1.3.1",
"tar": ">=7.5.19 <8.0.0",
"protobufjs": ">=7.5.5 <8.0.0"
},
"peerDependencyRules": {
"ignoreMissing": [
+2 -1
View File
@@ -65,6 +65,7 @@
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.5.1",
"hostinger-api-sdk": "^0.0.17",
"lodash": "4.17.21",
"micromatch": "4.0.8",
"nanoid": "3.3.18",
@@ -82,7 +83,7 @@
"react-dom": "19.2.7",
"resend": "^6.0.2",
"semver": "7.7.3",
"shell-quote": "^1.8.1",
"shell-quote": "^1.8.4",
"slugify": "^1.6.6",
"ssh2": "~1.16.0",
"toml": "3.0.0",
+1
View File
@@ -97,6 +97,7 @@ export const compose = pgTable("compose", {
isolatedDeploymentsVolume: boolean("isolatedDeploymentsVolume")
.notNull()
.default(false),
pullImages: boolean("pullImages").notNull().default(false),
triggerType: triggerType("triggerType").default("push"),
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
icon: text("icon"),
+2
View File
@@ -17,6 +17,7 @@ export * from "./gitea";
export * from "./github";
export * from "./gitlab";
export * from "./libsql";
export * from "./managed-server";
export * from "./mariadb";
export * from "./mongo";
export * from "./mount";
@@ -41,6 +42,7 @@ export * from "./shared";
export * from "./ssh-key";
export * from "./sso";
export * from "./tag";
export * from "./transfer";
export * from "./user";
export * from "./utils";
export * from "./vault-provider";
@@ -0,0 +1,76 @@
import { relations } from "drizzle-orm";
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { server } from "./server";
import { sshKeys } from "./ssh-key";
export const managedServerStatus = pgEnum("managedServerStatus", [
"pending",
"provisioning",
"configuring",
"ready",
"error",
"terminating",
"terminated",
]);
export const managedServer = pgTable("managed_server", {
managedServerId: text("managedServerId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "set null",
}),
/** UpCloud plan name, e.g. "2xCPU-4GB" */
plan: text("plan").notNull(),
status: managedServerStatus("status").notNull().default("pending"),
/** UpCloud server UUID */
providerVmId: text("providerVmId"),
/** UpCloud zone ID, e.g. "fi-hel1" */
zone: text("zone").notNull(),
/** SSH key used to access this server */
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
onDelete: "set null",
}),
ipAddress: text("ipAddress"),
hostname: text("hostname"),
stripeSubscriptionId: text("stripeSubscriptionId"),
stripePriceId: text("stripePriceId"),
errorMessage: text("errorMessage"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
updatedAt: text("updatedAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
export const managedServerRelations = relations(managedServer, ({ one }) => ({
organization: one(organization, {
fields: [managedServer.organizationId],
references: [organization.id],
}),
server: one(server, {
fields: [managedServer.serverId],
references: [server.serverId],
}),
}));
export const apiCreateManagedServer = z.object({
plan: z.string().min(1),
zone: z.string().min(1),
});
export const apiFindOneManagedServer = z.object({
managedServerId: z.string().min(1),
});
export const apiDeleteManagedServer = z.object({
managedServerId: z.string().min(1),
});
@@ -0,0 +1,9 @@
import { z } from "zod";
import { serviceType } from "./mount";
export const apiTransferService = z.object({
serviceType: z.enum(serviceType.enumValues),
serviceId: z.string().min(1),
targetServerId: z.string().min(1).nullable(),
removeSourceData: z.boolean().default(false),
});
+1255 -3935
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -50,10 +50,12 @@ export * from "./services/registry";
export * from "./services/rollbacks";
export * from "./services/schedule";
export * from "./services/security";
export * from "./services/managed-server";
export * from "./services/server";
export * from "./services/server-health";
export * from "./services/settings";
export * from "./services/ssh-key";
export * from "./services/transfer";
export * from "./services/user";
export * from "./services/vault-provider";
export * from "./services/volume-backups";
@@ -120,6 +122,7 @@ export * from "./utils/notifications/dokploy-restart";
export * from "./utils/notifications/server-threshold";
export * from "./utils/notifications/utils";
export * from "./utils/process/execAsync";
export * from "./utils/process/remoteStream";
export * from "./utils/process/spawnAsync";
export * from "./utils/providers/bitbucket";
export * from "./utils/providers/docker";
@@ -0,0 +1,54 @@
import { db } from "@dokploy/server/db";
import { managedServer } from "@dokploy/server/db/schema/managed-server";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
export type ManagedServer = typeof managedServer.$inferSelect;
export const createManagedServer = async (
input: typeof managedServer.$inferInsert,
) => {
const record = await db
.insert(managedServer)
.values(input)
.returning()
.then((r) => r[0]);
if (!record) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
return record;
};
export const findManagedServerById = async (managedServerId: string) => {
const record = await db.query.managedServer.findFirst({
where: eq(managedServer.managedServerId, managedServerId),
with: { server: true },
});
if (!record)
throw new TRPCError({ code: "NOT_FOUND", message: "Managed server not found" });
return record;
};
export const findManagedServersByOrg = async (organizationId: string) => {
return db.query.managedServer.findMany({
where: eq(managedServer.organizationId, organizationId),
with: { server: true },
orderBy: (t, { desc }) => [desc(t.createdAt)],
});
};
export const updateManagedServer = async (
managedServerId: string,
data: Partial<typeof managedServer.$inferInsert>,
) => {
return db
.update(managedServer)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(managedServer.managedServerId, managedServerId))
.returning()
.then((r) => r[0]);
};
export const deleteManagedServer = async (managedServerId: string) => {
return db
.delete(managedServer)
.where(eq(managedServer.managedServerId, managedServerId));
};
+1 -1
View File
@@ -11,7 +11,7 @@ import { eq } from "drizzle-orm";
import type { z } from "zod";
export const createSshKey = async (input: z.infer<typeof apiCreateSshKey>) => {
await db.transaction(async (tx) => {
return db.transaction(async (tx) => {
const sshKey = await tx
.insert(sshKeys)
.values(input)
+601
View File
@@ -0,0 +1,601 @@
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import {
type apiTransferService,
deployments,
network,
type ServiceType,
} from "@dokploy/server/db/schema";
import { removeService } from "@dokploy/server/utils/docker/utils";
import {
removeDirectoryCode,
removeMonitoringDirectory,
} from "@dokploy/server/utils/filesystem/directory";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
import {
readConfig,
readRemoteConfig,
removeTraefikConfig,
writeConfig,
writeConfigRemote,
} from "@dokploy/server/utils/traefik/application";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import { removeForwardAuthMiddleware } from "@dokploy/server/utils/traefik/forward-auth";
import {
deleteAllMiddlewares,
removePathMiddlewares,
} from "@dokploy/server/utils/traefik/middleware";
import { createRedirectMiddleware } from "@dokploy/server/utils/traefik/redirect";
import { createSecurityMiddleware } from "@dokploy/server/utils/traefik/security";
import { TRPCError } from "@trpc/server";
import { eq, inArray, sql } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import {
deployApplication,
findApplicationById,
updateApplication,
} from "./application";
import {
deployCompose,
findComposeById,
removeCompose,
startCompose,
stopCompose,
updateCompose,
} from "./compose";
import { deployLibsql, findLibsqlById, updateLibsqlById } from "./libsql";
import { deployMariadb, findMariadbById, updateMariadbById } from "./mariadb";
import { deployMongo, findMongoById, updateMongoById } from "./mongo";
import { createFileMount } from "./mount";
import { deployMySql, findMySqlById, updateMySqlById } from "./mysql";
import {
deployPostgres,
findPostgresById,
updatePostgresById,
} from "./postgres";
import { deployRedis, findRedisById, updateRedisById } from "./redis";
import { findServerById } from "./server";
export type TransferServiceInput = z.infer<typeof apiTransferService> & {
organizationId: string;
};
type Logger = (line: string) => void;
type ServiceStatus = "idle" | "running" | "done" | "error";
const findService = async (serviceType: ServiceType, serviceId: string) => {
switch (serviceType) {
case "application":
return await findApplicationById(serviceId);
case "compose":
return await findComposeById(serviceId);
case "postgres":
return await findPostgresById(serviceId);
case "mysql":
return await findMySqlById(serviceId);
case "mariadb":
return await findMariadbById(serviceId);
case "mongo":
return await findMongoById(serviceId);
case "redis":
return await findRedisById(serviceId);
case "libsql":
return await findLibsqlById(serviceId);
}
};
type TransferableService = Awaited<ReturnType<typeof findService>>;
type ApplicationService = Awaited<ReturnType<typeof findApplicationById>>;
type ComposeService = Awaited<ReturnType<typeof findComposeById>>;
const isApplication = (
service: TransferableService,
): service is ApplicationService => "applicationId" in service;
const isCompose = (service: TransferableService): service is ComposeService =>
"composeId" in service;
const updateServer = async (
serviceType: ServiceType,
serviceId: string,
data: { serverId: string | null; networkIds?: string[] | null },
) => {
switch (serviceType) {
case "application":
return await updateApplication(serviceId, data);
case "compose":
return await updateCompose(serviceId, { serverId: data.serverId });
case "postgres":
return await updatePostgresById(serviceId, data);
case "mysql":
return await updateMySqlById(serviceId, data);
case "mariadb":
return await updateMariadbById(serviceId, data);
case "mongo":
return await updateMongoById(serviceId, data);
case "redis":
return await updateRedisById(serviceId, data);
case "libsql":
return await updateLibsqlById(serviceId, data);
}
};
const updateStatus = async (
serviceType: ServiceType,
serviceId: string,
status: ServiceStatus,
) => {
switch (serviceType) {
case "application":
return await updateApplication(serviceId, { applicationStatus: status });
case "compose":
return await updateCompose(serviceId, { composeStatus: status });
case "postgres":
return await updatePostgresById(serviceId, {
applicationStatus: status,
});
case "mysql":
return await updateMySqlById(serviceId, { applicationStatus: status });
case "mariadb":
return await updateMariadbById(serviceId, { applicationStatus: status });
case "mongo":
return await updateMongoById(serviceId, { applicationStatus: status });
case "redis":
return await updateRedisById(serviceId, { applicationStatus: status });
case "libsql":
return await updateLibsqlById(serviceId, { applicationStatus: status });
}
};
const q = (value: string) => quote([value]);
const runOn = (serverId: string | null, command: string) =>
serverId ? execAsyncRemote(serverId, command) : execAsync(command);
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
const directoryExists = async (serverId: string | null, directory: string) => {
const { stdout } = await runOn(
serverId,
`[ -d ${q(directory)} ] && echo yes || echo no`,
);
return stdout.trim() === "yes";
};
const formatBytes = (bytes: number) => {
const mib = bytes / (1024 * 1024);
if (mib < 1) return `${(bytes / 1024).toFixed(1)} KiB`;
if (mib < 1024) return `${mib.toFixed(1)} MiB`;
return `${(mib / 1024).toFixed(2)} GiB`;
};
export const volumeExportCommand = (volume: string) =>
`docker run --rm -v ${q(volume)}:/data:ro alpine tar -czf - --numeric-owner -C /data .`;
export const volumeImportCommand = (volume: string) =>
`docker volume create ${q(volume)} >/dev/null && docker run --rm -i -v ${q(volume)}:/data alpine tar -xzf - --numeric-owner -C /data`;
export const directoryExportCommand = (directory: string) =>
`COPYFILE_DISABLE=1 tar -czf - --numeric-owner -C ${q(directory)} .`;
export const directoryImportCommand = (directory: string) =>
`mkdir -p ${q(directory)} && tar -xzf - --numeric-owner -C ${q(directory)}`;
const PROGRESS_STEP = 64 * 1024 * 1024;
const copyStream = async (
label: string,
source: { serverId: string | null; command: string },
target: { serverId: string | null; command: string },
log: Logger,
) => {
let reported = 0;
const bytes = await pipeBetweenServers({
source,
target,
onProgress: (transferred) => {
if (transferred - reported >= PROGRESS_STEP) {
reported = transferred;
log(` ${label}: ${formatBytes(transferred)} transferred`);
}
},
});
log(` ${label}: done (${formatBytes(bytes)})`);
};
const listVolumes = async (service: TransferableService) => {
const names = new Set<string>();
for (const mount of service.mounts) {
if (mount.type === "volume" && mount.volumeName) {
names.add(mount.volumeName);
}
}
if (isCompose(service)) {
const project = q(service.appName);
const { stdout } = await runOn(
service.serverId ?? null,
`docker volume ls -q --filter label=com.docker.compose.project=${project}; docker volume ls -q --filter label=com.docker.stack.namespace=${project}`,
);
for (const line of stdout.split("\n")) {
const name = line.trim();
if (name) names.add(name);
}
}
return [...names];
};
const listBindMounts = (service: TransferableService) => [
...new Set(
service.mounts
.filter((mount) => mount.type === "bind" && mount.hostPath)
.map((mount) => mount.hostPath as string),
),
];
const stopOnSource = async (service: TransferableService, log: Logger) => {
if (isCompose(service)) {
try {
await stopCompose(service.composeId);
} catch (error) {
log(` Could not stop compose: ${errorMessage(error)}`);
}
return;
}
await runOn(
service.serverId ?? null,
`docker service scale ${q(service.appName)}=0 >/dev/null 2>&1 || true`,
);
};
const startOnSource = async (service: TransferableService, log: Logger) => {
if (isCompose(service)) {
if (service.composeType === "stack") {
log(
" Stack was removed on the source server, run Deploy to bring it back",
);
return;
}
try {
await startCompose(service.composeId);
} catch (error) {
log(` Could not start compose: ${errorMessage(error)}`);
}
return;
}
const replicas = isApplication(service) ? service.replicas : 1;
await runOn(
service.serverId ?? null,
`docker service scale ${q(service.appName)}=${replicas} >/dev/null 2>&1 || true`,
);
};
const resolveNetworkIds = async (
networkIds: string[] | null | undefined,
targetServerId: string | null,
) => {
if (!networkIds || networkIds.length === 0) {
return { kept: [] as string[], dropped: [] as string[] };
}
const rows = await db.query.network.findMany({
where: inArray(network.networkId, networkIds),
columns: { networkId: true, name: true, serverId: true },
});
return {
kept: rows
.filter((row) => (row.serverId ?? null) === targetServerId)
.map((row) => row.networkId),
dropped: rows
.filter((row) => (row.serverId ?? null) !== targetServerId)
.map((row) => row.name),
};
};
const moveTraefikConfig = async (
source: ApplicationService,
moved: ApplicationService,
log: Logger,
) => {
const config = source.serverId
? await readRemoteConfig(source.serverId, source.appName)
: readConfig(source.appName);
if (config) {
if (moved.serverId) {
await writeConfigRemote(moved.serverId, moved.appName, config);
} else {
writeConfig(moved.appName, config);
}
}
for (const domain of moved.domains) {
await manageDomain(moved, domain);
}
for (const security of moved.security) {
await createSecurityMiddleware(moved, security);
}
for (const redirect of moved.redirects) {
await createRedirectMiddleware(moved, redirect);
}
log(` Traefik configuration recreated (${moved.domains.length} domains)`);
};
const removeSourceTraefikConfig = async (source: ApplicationService) => {
for (const domain of source.domains) {
await removePathMiddlewares(source, domain.uniqueConfigKey);
await removeForwardAuthMiddleware(source, domain.uniqueConfigKey);
}
await deleteAllMiddlewares(source);
await removeTraefikConfig(source.appName, source.serverId);
};
const moveDeploymentLogs = async (
service: ApplicationService | ComposeService,
targetServerId: string | null,
log: Logger,
) => {
const sourceServerId = service.serverId ?? null;
const from = path.join(paths(!!sourceServerId).LOGS_PATH, service.appName);
const to = path.join(paths(!!targetServerId).LOGS_PATH, service.appName);
if (!(await directoryExists(sourceServerId, from))) return;
log("Copying deployment logs");
await copyStream(
"deployment logs",
{ serverId: sourceServerId, command: directoryExportCommand(from) },
{ serverId: targetServerId, command: directoryImportCommand(to) },
log,
);
if (from !== to) {
await db
.update(deployments)
.set({ logPath: sql`replace(${deployments.logPath}, ${from}, ${to})` })
.where(
isCompose(service)
? eq(deployments.composeId, service.composeId)
: eq(deployments.applicationId, service.applicationId),
);
}
await runOn(sourceServerId, `rm -rf ${q(from)}`);
};
const cleanupSource = async (
service: TransferableService,
volumes: string[],
removeSourceData: boolean,
log: Logger,
) => {
const serverId = service.serverId ?? null;
const steps: Array<() => Promise<unknown>> = [];
if (isApplication(service)) {
steps.push(
() => removeSourceTraefikConfig(service),
() => removeService(service.appName, serverId),
() => removeDirectoryCode(service.appName, serverId),
() => removeMonitoringDirectory(service.appName, serverId),
);
} else if (isCompose(service)) {
steps.push(() => removeCompose(service, removeSourceData));
} else {
steps.push(
() => removeService(service.appName, serverId),
() => removeDirectoryCode(service.appName, serverId),
);
}
if (removeSourceData && volumes.length > 0) {
steps.push(() =>
runOn(
serverId,
`docker volume rm ${volumes.map(q).join(" ")} >/dev/null 2>&1 || true`,
),
);
}
for (const step of steps) {
try {
await step();
} catch (error) {
log(` Warning: ${errorMessage(error)}`);
}
}
if (!removeSourceData && volumes.length > 0) {
log(` Volumes kept on the source server: ${volumes.join(", ")}`);
}
};
const deployOnTarget = async (
serviceType: ServiceType,
serviceId: string,
targetName: string,
log: Logger,
) => {
log(`Deploying on ${targetName}`);
const deployment = {
titleLog: "Deploy after transfer",
descriptionLog: `Transferred to ${targetName}`,
};
switch (serviceType) {
case "application":
log("Follow the Deployments tab for the build log");
await updateStatus(serviceType, serviceId, "running");
return await deployApplication({
applicationId: serviceId,
...deployment,
});
case "compose":
log("Follow the Deployments tab for the build log");
await updateStatus(serviceType, serviceId, "running");
return await deployCompose({ composeId: serviceId, ...deployment });
case "postgres":
return await deployPostgres(serviceId, log);
case "mysql":
return await deployMySql(serviceId, log);
case "mariadb":
return await deployMariadb(serviceId, log);
case "mongo":
return await deployMongo(serviceId, log);
case "redis":
return await deployRedis(serviceId, log);
case "libsql":
return await deployLibsql(serviceId, log);
}
};
export const transferService = async (
input: TransferServiceInput,
log: Logger,
) => {
const { serviceType, serviceId, targetServerId, removeSourceData } = input;
const service = await findService(serviceType, serviceId);
if (service.environment.project.organizationId !== input.organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to transfer this service",
});
}
const sourceServerId = service.serverId ?? null;
if (sourceServerId === targetServerId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The service is already on the selected server",
});
}
const targetServer = targetServerId
? await findServerById(targetServerId)
: null;
if (targetServer) {
if (targetServer.organizationId !== input.organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to use the target server",
});
}
if (
targetServer.serverStatus !== "active" ||
targetServer.serverType !== "deploy"
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The target server is not available for deployments",
});
}
}
const sourceName = service.server?.name ?? "Dokploy Server";
const targetName = targetServer?.name ?? "Dokploy Server";
const originalNetworkIds =
"networkIds" in service ? service.networkIds : undefined;
let switched = false;
log(`Transferring ${service.appName} from ${sourceName} to ${targetName}`);
try {
log(`Stopping service on ${sourceName}`);
await stopOnSource(service, log);
await updateStatus(serviceType, serviceId, "running");
const volumes = await listVolumes(service);
const bindMounts = listBindMounts(service);
log(
`Found ${volumes.length} volume(s) and ${bindMounts.length} bind mount(s)`,
);
for (const volume of volumes) {
log(`Copying volume ${volume}`);
await copyStream(
volume,
{ serverId: sourceServerId, command: volumeExportCommand(volume) },
{ serverId: targetServerId, command: volumeImportCommand(volume) },
log,
);
}
for (const hostPath of bindMounts) {
if (!(await directoryExists(sourceServerId, hostPath))) {
log(`Skipping bind mount ${hostPath}: not found on ${sourceName}`);
continue;
}
log(`Copying bind mount ${hostPath}`);
await copyStream(
hostPath,
{
serverId: sourceServerId,
command: directoryExportCommand(hostPath),
},
{
serverId: targetServerId,
command: directoryImportCommand(hostPath),
},
log,
);
}
const networks = await resolveNetworkIds(
originalNetworkIds,
targetServerId,
);
if (networks.dropped.length > 0) {
log(
`Detaching networks not available on ${targetName}: ${networks.dropped.join(", ")}`,
);
}
await updateServer(serviceType, serviceId, {
serverId: targetServerId,
networkIds: originalNetworkIds === undefined ? undefined : networks.kept,
});
switched = true;
const moved = await findService(serviceType, serviceId);
const fileMounts = moved.mounts.filter((mount) => mount.type === "file");
if (fileMounts.length > 0) {
log(`Recreating ${fileMounts.length} file mount(s) on ${targetName}`);
for (const mount of fileMounts) {
await createFileMount(mount.mountId);
}
}
if (isApplication(service) && isApplication(moved)) {
log(`Recreating Traefik configuration on ${targetName}`);
await moveTraefikConfig(service, moved, log);
}
if (isApplication(service) || isCompose(service)) {
await moveDeploymentLogs(service, targetServerId, log);
}
log(`Cleaning up ${sourceName}`);
await cleanupSource(service, volumes, removeSourceData, log);
await updateStatus(serviceType, serviceId, "idle");
} catch (error) {
if (switched) {
await updateServer(serviceType, serviceId, {
serverId: sourceServerId,
networkIds: originalNetworkIds,
});
}
log(`Data already copied to ${targetName} was left in place`);
log(`Restoring service on ${sourceName}`);
await startOnSource(service, log);
await updateStatus(serviceType, serviceId, "error");
throw error;
}
try {
await deployOnTarget(serviceType, serviceId, targetName, log);
} catch (error) {
await updateStatus(serviceType, serviceId, "error");
log(
`Data was transferred but the deployment on ${targetName} failed, run Deploy to retry`,
);
throw error;
}
return {
serviceType,
serviceId,
appName: service.appName,
targetServerId,
targetName,
};
};
@@ -139,7 +139,8 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
const envFileFlag = compose.createEnvFile
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
: "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans`;
const pullFlag = compose.pullImages ? " --pull always" : "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans${pullFlag}`;
} else if (composeType === "stack") {
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}
+20
View File
@@ -497,6 +497,7 @@ export interface ComposeSpecification {
*/
include?: DefinitionsInclude[];
services?: PropertiesServices;
models?: PropertiesModels;
networks?: PropertiesNetworks;
volumes?: PropertiesVolumes;
secrets?: PropertiesSecrets;
@@ -648,6 +649,15 @@ export interface DefinitionsService {
mem_swappiness?: number;
memswap_limit?: number | string;
network_mode?: string;
models?:
| ListOfStrings
| {
[k: string]: {
endpoint_var?: string;
model_var?: string;
[key: `x-${string}`]: unknown;
} | null;
};
networks?:
| ListOfStrings
| {
@@ -877,3 +887,13 @@ export interface DefinitionsConfig {
*/
[k: string]: unknown;
}
export interface PropertiesModels {
[k: string]: DefinitionsModel;
}
export interface DefinitionsModel {
name?: string;
model: string;
context_size?: number;
runtime_flags?: string[];
[key: `x-${string}`]: unknown;
}
+205
View File
@@ -0,0 +1,205 @@
import {
BillingCatalogApi,
BillingSubscriptionsApi,
Configuration,
VPSDataCentersApi,
VPSVirtualMachineApi,
} from "hostinger-api-sdk";
export type {
BillingV1CatalogCatalogItemResource as HostingerCatalogItem,
VPSV1DataCenterDataCenterResource as HostingerDataCenter,
VPSV1VirtualMachinePurchaseRequest as HostingerPurchaseRequest,
VPSV1VirtualMachineVirtualMachineResource as HostingerVM,
} from "hostinger-api-sdk";
// Correct base URL — api.hostinger.com returns 530, developers.hostinger.com is the real gateway
const HOSTINGER_BASE_PATH = "https://developers.hostinger.com";
function getConfig() {
const apiKey = process.env.HOSTINGER_API_KEY;
if (!apiKey) throw new Error("HOSTINGER_API_KEY is not set");
return new Configuration({
basePath: HOSTINGER_BASE_PATH,
accessToken: apiKey,
});
}
function getVmApi() {
return new VPSVirtualMachineApi(getConfig());
}
export async function getHostingerDataCenters() {
try {
const api = new VPSDataCentersApi(getConfig());
const res = await api.getDataCenterListV1();
return res.data;
} catch (error) {
console.log(error);
}
}
export async function getHostingerVpsCatalog() {
const api = new BillingCatalogApi(getConfig());
const res = await api.getCatalogItemListV1("VPS");
return res.data;
}
const FAKE_VM_ID = 999999;
export async function purchaseHostingerVps(
body: import("hostinger-api-sdk").VPSV1VirtualMachinePurchaseRequest,
): Promise<import("hostinger-api-sdk").BillingV1OrderVirtualMachineOrderResource> {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log("[hostinger dry-run] purchaseHostingerVps called, skipping real purchase");
return {
virtual_machine: {
id: FAKE_VM_ID,
hostname: body.setup.hostname,
state: "initial",
ipv4: [],
subscription_id: "dry-run-sub-123",
},
} as never;
}
const api = getVmApi();
const res = await api.purchaseNewVirtualMachineV1(body);
return res.data;
}
export async function getHostingerVm(vmId: number) {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log(`[hostinger dry-run] getHostingerVm(${vmId})`);
return {
id: vmId,
hostname: "dry-run-vm",
state: "running",
ipv4: [{ address: "1.2.3.4" }],
subscription_id: "dry-run-sub-123",
} as never;
}
const api = getVmApi();
const res = await api.getVirtualMachineDetailsV1(vmId);
return res.data;
}
export async function stopHostingerVm(vmId: number) {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log(`[hostinger dry-run] stopHostingerVm(${vmId})`);
return;
}
const api = getVmApi();
await api.stopVirtualMachineV1(vmId);
}
export async function cancelHostingerSubscription(subscriptionId: string) {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log(`[hostinger dry-run] cancelHostingerSubscription(${subscriptionId})`);
return;
}
const api = new BillingSubscriptionsApi(getConfig());
await api.cancelSubscriptionV1(subscriptionId, {
reason_code: "other",
cancel_option: "immediately",
});
}
/** Ubuntu 22.04 LTS template ID on Hostinger */
export const UBUNTU_22_TEMPLATE_ID = 1009;
/**
* Markup multiplier applied to Hostinger's catalog price to get Dokploy's user price.
* Hostinger KVM2 = ~$24.49/mo → Dokploy charges $45/mo (~84% markup).
*/
const MARKUP = 1.84;
export interface ManagedServerPlan {
id: string;
name: string;
hostingerItemIdMonthly: string;
hostingerItemIdAnnual: string;
cpus: number;
memoryMb: number;
diskMb: number;
bandwidthMb: number;
/** Price in cents Hostinger charges us monthly */
hostingerPriceCentsMonthly: number;
/** Price in cents we charge the user monthly */
dokployPriceCentsMonthly: number;
/** Price in cents we charge the user annually */
dokployPriceCentsAnnual: number;
}
/** KVM plan IDs offered through Dokploy (excludes Game Panel plans) */
const OFFERED_PLAN_IDS = [
"hostingercom-vps-kvm1",
"hostingercom-vps-kvm2",
"hostingercom-vps-kvm4",
"hostingercom-vps-kvm8",
];
/**
* Fetches live VPS plans from Hostinger catalog and applies Dokploy markup.
* Only returns standard KVM plans (not Game Panel variants).
*/
export async function getManagedServerPlans(): Promise<ManagedServerPlan[]> {
const catalog = await getHostingerVpsCatalog();
const plans: ManagedServerPlan[] = [];
for (const item of catalog) {
if (!OFFERED_PLAN_IDS.includes(item.id ?? "")) continue;
const meta = item.metadata as Record<string, string> | null;
const cpus = Number(meta?.cpus ?? 0);
const memoryMb = Number(meta?.memory ?? 0);
const diskMb = Number(meta?.disk_space ?? 0);
const bandwidthMb = Number(meta?.bandwidth ?? 0);
const monthlyPrice = item.prices?.find(
(p) => p.period === 1 && p.period_unit === "month",
);
const annualPrice = item.prices?.find(
(p) => p.period === 1 && p.period_unit === "year",
);
if (!monthlyPrice) continue;
const hostingerMonthly = monthlyPrice.price ?? 0;
const hostingerAnnual = annualPrice?.price ?? hostingerMonthly * 12;
// Apply markup and round to nearest $0.50 (50 cents)
const dokployMonthly = Math.ceil((hostingerMonthly * MARKUP) / 50) * 50;
const dokployAnnual = Math.ceil((hostingerAnnual * MARKUP) / 50) * 50;
// Derive hostinger item IDs for monthly and annual billing
const hostingerItemIdMonthly = monthlyPrice.id ?? `${item.id}-usd-1m`;
const hostingerItemIdAnnual = annualPrice?.id ?? `${item.id}-usd-1y`;
// Map hostinger plan names to friendly names
const friendlyNames: Record<string, string> = {
"hostingercom-vps-kvm1": "Starter",
"hostingercom-vps-kvm2": "Basic",
"hostingercom-vps-kvm4": "Growth",
"hostingercom-vps-kvm8": "Scale",
};
plans.push({
id: item.id ?? "",
name: friendlyNames[item.id ?? ""] ?? item.name ?? item.id ?? "",
hostingerItemIdMonthly,
hostingerItemIdAnnual,
cpus,
memoryMb,
diskMb,
bandwidthMb,
hostingerPriceCentsMonthly: hostingerMonthly,
dokployPriceCentsMonthly: dokployMonthly,
dokployPriceCentsAnnual: dokployAnnual,
});
}
return plans;
}
export type ManagedServerPlanId = string;
@@ -196,7 +196,7 @@ export const sendDatabaseBackupNotifications = async ({
`🛠Project: ${projectName}\n` +
`⚙️Application: ${applicationName}\n` +
`❔Type: ${databaseType}\n` +
`📂Database Name: ${databaseName}` +
`📂Database Name: ${databaseName}\n` +
`🕒Date: ${date.toLocaleString()}\n` +
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
);
@@ -0,0 +1,127 @@
import { spawn } from "node:child_process";
import { findServerById } from "@dokploy/server/services/server";
import { Client } from "ssh2";
export interface ProcessStream {
stdin: NodeJS.WritableStream;
stdout: NodeJS.ReadableStream;
stderr: NodeJS.ReadableStream;
exit: Promise<number>;
close: () => void;
}
export const openProcessStream = async (
serverId: string | null,
command: string,
): Promise<ProcessStream> => {
if (!serverId) {
const child = spawn("sh", ["-c", command]);
const exit = new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code ?? 1));
});
return {
stdin: child.stdin,
stdout: child.stdout,
stderr: child.stderr,
exit,
close: () => child.kill(),
};
}
const server = await findServerById(serverId);
if (!server.sshKeyId) throw new Error("No SSH key available for this server");
return new Promise((resolve, reject) => {
const conn = new Client();
conn
.once("ready", () => {
conn.exec(command, (err, stream) => {
if (err) {
conn.end();
reject(err);
return;
}
let exitCode = 1;
stream.once("exit", (code: number | null) => {
exitCode = code ?? 1;
});
const exit = new Promise<number>((resolveExit) => {
stream.once("close", () => {
conn.end();
resolveExit(exitCode);
});
});
resolve({
stdin: stream,
stdout: stream,
stderr: stream.stderr,
exit,
close: () => {
stream.close();
conn.end();
},
});
});
})
.once("error", (err) => {
conn.end();
reject(err);
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
readyTimeout: 30_000,
keepaliveInterval: 15_000,
});
});
};
const collectOutput = (stream: NodeJS.ReadableStream) => {
const chunks: string[] = [];
stream.on("data", (chunk) => chunks.push(chunk.toString()));
return () => chunks.join("").trim();
};
export const pipeBetweenServers = async ({
source,
target,
onProgress,
}: {
source: { serverId: string | null; command: string };
target: { serverId: string | null; command: string };
onProgress?: (bytes: number) => void;
}) => {
const to = await openProcessStream(target.serverId, target.command);
const targetError = collectOutput(to.stderr);
to.stdout.resume();
to.stdin.on("error", () => {});
const from = await openProcessStream(source.serverId, source.command);
const sourceError = collectOutput(from.stderr);
let bytes = 0;
from.stdout.on("data", (chunk: Buffer) => {
bytes += chunk.length;
onProgress?.(bytes);
});
from.stdout.pipe(to.stdin);
const targetExit = to.exit.then((code) => {
if (code !== 0) from.close();
return code;
});
const [sourceCode, targetCode] = await Promise.all([from.exit, targetExit]);
const failures = [
sourceCode !== 0 &&
`source exited with code ${sourceCode}: ${sourceError()}`,
targetCode !== 0 &&
`target exited with code ${targetCode}: ${targetError()}`,
].filter(Boolean);
if (failures.length > 0) {
throw new Error(failures.join("\n"));
}
return bytes;
};
+220
View File
@@ -0,0 +1,220 @@
import { defaultCommand } from "../setup/server-setup";
const UPCLOUD_BASE = "https://api.upcloud.com/1.3";
const UBUNTU_22_TEMPLATE_UUID = "01000000-0000-4000-8000-000030220200";
export { UBUNTU_22_TEMPLATE_UUID };
export interface DokployPlan {
id: string;
name: string;
priceCents: number;
upcloudPlan: string;
cpus: number;
memoryGb: number;
storageGb: number;
}
export const DOKPLOY_PLANS: DokployPlan[] = [
{
id: "hobby",
name: "Hobby",
priceCents: 2499,
upcloudPlan: "1xCPU-2GB",
cpus: 1,
memoryGb: 2,
storageGb: 50,
},
{
id: "starter",
name: "Starter",
priceCents: 4900,
upcloudPlan: "2xCPU-4GB",
cpus: 2,
memoryGb: 4,
storageGb: 80,
},
{
id: "pro",
name: "Pro",
priceCents: 9900,
upcloudPlan: "4xCPU-8GB",
cpus: 4,
memoryGb: 8,
storageGb: 160,
},
{
id: "business",
name: "Business",
priceCents: 19900,
upcloudPlan: "HIMEM-4xCPU-16GB",
cpus: 4,
memoryGb: 16,
storageGb: 320,
},
];
function getHeaders() {
const token = process.env.UPCLOUD_TOKEN;
if (!token) throw new Error("UPCLOUD_TOKEN is not set");
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
};
}
async function upcloudFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch(`${UPCLOUD_BASE}${path}`, {
...options,
headers: { ...getHeaders(), ...(options.headers ?? {}) },
});
if (!res.ok) {
let body: unknown;
try {
body = await res.json();
} catch {
body = await res.text();
}
const err = new Error(
`UpCloud API error ${res.status}: ${JSON.stringify(body)}`,
) as Error & { response: { status: number; data: unknown } };
err.response = { status: res.status, data: body };
throw err;
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export interface UpCloudZone {
id: string;
description: string;
public: "yes" | "no";
}
export type UpCloudZoneContinent = "Europe" | "Americas" | "Asia-Pacific";
export interface UpCloudZoneWithContinent extends UpCloudZone {
continent: UpCloudZoneContinent;
}
const ZONE_CONTINENT: Record<string, UpCloudZoneContinent> = {
"fi-hel1": "Europe",
"fi-hel2": "Europe",
"de-fra1": "Europe",
"nl-ams1": "Europe",
"uk-lon1": "Europe",
"se-sto1": "Europe",
"dk-cph1": "Europe",
"no-svg1": "Europe",
"pl-waw1": "Europe",
"es-mad1": "Europe",
"us-nyc1": "Americas",
"us-chi1": "Americas",
"us-sjo1": "Americas",
"sg-sin1": "Asia-Pacific",
"au-syd1": "Asia-Pacific",
};
export async function getUpCloudZones(): Promise<UpCloudZoneWithContinent[]> {
const data = await upcloudFetch<{ zones: { zone: UpCloudZone[] } }>("/zone");
return data.zones.zone
.filter((z) => z.public === "yes" && z.id in ZONE_CONTINENT)
.map((z) => ({ ...z, continent: ZONE_CONTINENT[z.id]! }))
.sort(
(a, b) =>
a.continent.localeCompare(b.continent) ||
a.description.localeCompare(b.description),
);
}
export interface UpCloudServer {
uuid: string;
hostname: string;
title: string;
state: "started" | "stopped" | "maintenance" | "error";
zone: string;
plan: string;
core_number: string;
memory_amount: string;
ip_addresses: {
ip_address: Array<{
access: "public" | "utility" | "private";
address: string;
family: "IPv4" | "IPv6";
}>;
};
}
export async function createUpCloudServer(params: {
hostname: string;
upcloudPlan: string;
zone: string;
sshKey: string;
}): Promise<UpCloudServer> {
const userDataScript = `#!/bin/bash\n${defaultCommand(false)}`;
const body = {
server: {
zone: params.zone,
title: params.hostname,
hostname: params.hostname,
plan: params.upcloudPlan,
firewall: "on",
metadata: "yes",
user_data: userDataScript,
login_user: {
username: "root",
ssh_keys: {
ssh_key: [params.sshKey],
},
create_password: "no",
},
storage_devices: {
storage_device: [
{
action: "clone",
storage: UBUNTU_22_TEMPLATE_UUID,
title: `${params.hostname}-disk`,
tier: "maxiops",
},
],
},
},
};
const data = await upcloudFetch<{ server: UpCloudServer }>("/server", {
method: "POST",
body: JSON.stringify(body),
});
return data.server;
}
export async function getUpCloudServer(uuid: string): Promise<UpCloudServer> {
const data = await upcloudFetch<{ server: UpCloudServer }>(`/server/${uuid}`);
return data.server;
}
export async function stopUpCloudServer(uuid: string): Promise<void> {
await upcloudFetch(`/server/${uuid}/stop`, {
method: "POST",
body: JSON.stringify({ stop_server: { stop_type: "hard", timeout: "60" } }),
});
}
export async function deleteUpCloudServer(uuid: string): Promise<void> {
await upcloudFetch(`/server/${uuid}?storages=1&backups=delete`, {
method: "DELETE",
});
}
export function getPublicIPv4(server: UpCloudServer): string | undefined {
return server.ip_addresses.ip_address.find(
(ip) => ip.access === "public" && ip.family === "IPv4",
)?.address;
}
@@ -79,6 +79,12 @@ const readPath = async (
// still reports success. Single secrets read via /raw/{name} expand by
// default, which makes the difference easy to miss in the UI.
expandSecretReferences: "true",
// Secrets pulled in through "Import Secrets" are not part of `secrets`:
// they come back in a separate `imports` array, and only when this is
// asked for. Note the snake_case — `includeImports` is silently ignored
// (the request still returns 200 with an empty `imports`), which is why
// an imported secret looked like it simply did not exist.
include_imports: "true",
});
const response = await vaultFetch(
`${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`,
@@ -93,9 +99,17 @@ const readPath = async (
const body = (await response.json()) as {
secrets?: { secretKey: string; secretValue: string }[];
imports?: { secrets?: { secretKey: string; secretValue: string }[] }[];
};
const secrets: Record<string, string> = {};
// Imported first, then the folder's own: Infisical resolves a name defined
// in both in favour of the local one, so writing local last preserves that.
for (const imported of body.imports ?? []) {
for (const secret of imported.secrets ?? []) {
secrets[secret.secretKey] = secret.secretValue;
}
}
for (const secret of body.secrets ?? []) {
secrets[secret.secretKey] = secret.secretValue;
}
+403 -401
View File
File diff suppressed because it is too large Load Diff