diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md index 226083304..9418a626b 100644 --- a/.claude/skills/fix-issue/SKILL.md +++ b/.claude/skills/fix-issue/SKILL.md @@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree: until it answers (usually ~10-15s). 3. Use `http://localhost:$PORT` as the base URL for Playwright navigation. -Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from +Note: `mcp__dokploy__*` resolves its URL from `$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port discovered mid-session. If those tools are unavailable or point at the wrong instance, fall back to `curl`/`gh api` for API-level checks, or ask the user 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 08d6b8a5b..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,37 @@ jobs: needs: [combine-manifests] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + permissions: + contents: write outputs: version: ${{ steps.get_version.outputs.version }} npm_version: ${{ steps.get_version.outputs.npm_version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 + persist-credentials: false - name: Get version id: get_version run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT" - name: Fetch install.sh + env: + RELEASE_VERSION: ${{ steps.get_version.outputs.version }} run: | curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; } grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; } - { head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh + { head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh mv install-pinned.sh install.sh - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.get_version.outputs.version }} target_commitish: ${{ github.sha }} @@ -179,15 +193,19 @@ jobs: needs: [generate-release] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + env: + NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: 10.22.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 24.4.0 cache: pnpm @@ -202,63 +220,63 @@ jobs: git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo cd /tmp/mcp-repo - jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json src/generated/openapi.json + cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}" + echo "✅ MCP repo synced to version $NPM_VERSION" - name: Sync version to CLI repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo cd /tmp/cli-repo - jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json ./openapi.json + cp $GITHUB_WORKSPACE/openapi.json ./openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}" + echo "✅ CLI repo synced to version $NPM_VERSION" - name: Sync version to SDK repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo cd /tmp/sdk-repo - jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json ./openapi.json + cp $GITHUB_WORKSPACE/openapi.json ./openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}" + echo "✅ SDK repo synced to version $NPM_VERSION" diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 48589575f..fd0823a7a 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -6,15 +6,20 @@ on: pull_request: branches: [canary] +permissions: + contents: read + jobs: format: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup biomeJs - uses: biomejs/setup-biome@v2 + uses: biomejs/setup-biome@v2 - name: Run Biome formatter run: biome format --write diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml index c85e118d6..30a3dda7c 100644 --- a/.github/workflows/hotfix-cherry-pick.yml +++ b/.github/workflows/hotfix-cherry-pick.yml @@ -16,17 +16,19 @@ jobs: contents: write steps: - name: Checkout main - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: main fetch-depth: 0 token: ${{ secrets.HOTFIX_PUSH_TOKEN }} - name: Cherry-pick fix to main + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" - SHA="${{ github.event.pull_request.merge_commit_sha }}" + SHA="$MERGE_SHA" if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then git cherry-pick -x -m 1 "$SHA" else diff --git a/.github/workflows/hotfix-release.yml b/.github/workflows/hotfix-release.yml index 7e473b3d2..3271df861 100644 --- a/.github/workflows/hotfix-release.yml +++ b/.github/workflows/hotfix-release.yml @@ -7,12 +7,16 @@ concurrency: group: hotfix-to-main cancel-in-progress: false +# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token. +permissions: + contents: read + jobs: release: runs-on: ubuntu-latest steps: - name: Checkout main - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: main token: ${{ secrets.HOTFIX_PUSH_TOKEN }} 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 227022a90..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@v5 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 with: node-version: 24.4.0 cache: "pnpm" 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 index f73b7276e..eea581175 100644 --- a/.github/workflows/upgrade-integration-test.yml +++ b/.github/workflows/upgrade-integration-test.yml @@ -28,6 +28,9 @@ env: DOKPLOY_SERVICE: dokploy DOKPLOY_PORT: 3000 +permissions: + contents: read + # ────────────────────────────────────────────────────────────────────────────── jobs: @@ -421,8 +424,10 @@ jobs: # ── Job summary ──────────────────────────────────────────────────────── - name: Write job summary if: always() + env: + JOB_STATUS: ${{ job.status }} run: | - STATUS="${{ job.status }}" + STATUS="$JOB_STATUS" ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌" cat >> "$GITHUB_STEP_SUMMARY" < 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__/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-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/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts index b7223ca4a..1c7ee65b0 100644 --- a/apps/dokploy/__test__/compose/env-file-literals.test.ts +++ b/apps/dokploy/__test__/compose/env-file-literals.test.ts @@ -54,7 +54,16 @@ const inputEncoding: Record = { DB_HOST: '"${UNDEFINED_HOST:-localhost}"', }; -describe("getCreateEnvFileCommand", () => { +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 }); 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__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index f27e7bc60..8edc07885 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -323,9 +323,11 @@ describe("cloudflareClient.upsertRecord", () => { expect(createInit.method).toBe("POST"); }); - it("updates the existing record instead of creating a duplicate", async () => { + it("updates the existing record when content matches", async () => { mockFetch - .mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }])) + .mockResolvedValueOnce( + cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]), + ) .mockResolvedValueOnce(cfSuccess({ id: "existing-1" })); const result = await cloudflareClient.upsertRecord(config, { @@ -344,6 +346,25 @@ describe("cloudflareClient.upsertRecord", () => { expect(updateInit.method).toBe("PUT"); }); + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]), + ) + .mockResolvedValueOnce(cfSuccess({ id: "new-2" })); + + const result = await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "new-2" }); + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(createInit.method).toBe("POST"); + }); + it("defaults ttl to 1 (automatic) when not provided", async () => { mockFetch .mockResolvedValueOnce(cfSuccess([])) 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 index 2275e9c5f..a72b882ba 100644 --- a/apps/dokploy/__test__/dns/porkbun.test.ts +++ b/apps/dokploy/__test__/dns/porkbun.test.ts @@ -121,9 +121,11 @@ describe("porkbunClient.upsertRecord", () => { expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/"); }); - it("edits the existing record instead of creating a duplicate", async () => { + it("edits the existing record when content matches", async () => { mockFetch - .mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] })) + .mockResolvedValueOnce( + pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }), + ) .mockResolvedValueOnce(pbSuccess({})); const result = await porkbunClient.upsertRecord(config, { @@ -138,6 +140,30 @@ describe("porkbunClient.upsertRecord", () => { expect(editUrl).toContain("/dns/edit/example.com/existing-1"); }); + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }), + ) + .mockResolvedValueOnce(pbSuccess({ id: "new-2" })); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "new-2" }); + const [createUrl, createInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(createUrl).toContain("/dns/create/example.com"); + const body = JSON.parse(createInit.body as string); + expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" }); + }); + it("defaults ttl to 600 when not provided", async () => { mockFetch .mockResolvedValueOnce(pbSuccess({ records: [] })) 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 index ea57b6800..b1381cd05 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -20,6 +20,7 @@ import { import { azureClient } from "@dokploy/server/utils/vault/azure"; import { dopplerClient } from "@dokploy/server/utils/vault/doppler"; import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp"; +import { infisicalClient } from "@dokploy/server/utils/vault/infisical"; import { phaseClient } from "@dokploy/server/utils/vault/phase"; import { scalewayClient } from "@dokploy/server/utils/vault/scaleway"; @@ -431,6 +432,223 @@ describe("azure client", () => { }); }); +describe("infisical client", () => { + const config = { + providerType: "infisical" as const, + siteUrl: "https://app.infisical.com", + clientId: "client-1", + clientSecret: "client-secret", + projectId: "workspace-1", + environmentSlug: "prod", + secretPath: "/frontend", + }; + + const loginResponse = () => jsonResponse({ accessToken: "token-1" }); + const list = (secrets: Record) => + 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)); 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__/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__/setup/monitoring-setup.real.test.ts b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts index 89b8b390d..590d67b21 100644 --- a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts +++ b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts @@ -154,7 +154,16 @@ const hasRealMonitoring = () => { ); }; -describe.skipIf(hasRealMonitoring())( +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 () => { 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__/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/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/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/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/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx index ac2531f94..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 { diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 8bb763add..a715381cb 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { domainId, }, { - enabled: !!domainId, + enabled: isOpen && !!domainId, }, ); @@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { applicationId: id, }, { - enabled: !!id, + enabled: isOpen && !!id, }, ) : api.compose.one.useQuery( @@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { composeId: id, }, { - enabled: !!id, + enabled: isOpen && !!id, }, ); @@ -187,9 +187,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { api.domain.generateDomain.useMutation(); const { data: canGenerateTraefikMeDomains } = - api.domain.canGenerateTraefikMeDomains.useQuery({ - serverId: application?.serverId || "", - }); + api.domain.canGenerateTraefikMeDomains.useQuery( + { + serverId: application?.serverId || "", + }, + { + enabled: isOpen, + }, + ); const { data: services, @@ -204,7 +209,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { { retry: false, refetchOnWindowFocus: false, - enabled: type === "compose" && !!id, + enabled: isOpen && type === "compose" && !!id, }, ); 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/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/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/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/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index 4c78b0e96..57887cad9 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"; @@ -102,12 +96,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 e479d9275..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, @@ -27,7 +25,6 @@ import Link from "next/link"; import { useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -97,14 +94,21 @@ function getServiceInfo(d: DeploymentRow) { return null; } -export function ShowDeploymentsTable() { +interface ShowDeploymentsTableProps { + globalFilter: string; + statusFilter: string; + typeFilter: string; +} + +export function ShowDeploymentsTable({ + globalFilter, + statusFilter, + typeFilter, +}: ShowDeploymentsTableProps) { const [sorting, setSorting] = useState([ { 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, @@ -446,177 +450,140 @@ export function ShowDeploymentsTable() { columns, state: { sorting, - columnFilters, - globalFilter, pagination, }, onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onGlobalFilterChange: setGlobalFilter, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), }); return ( -
-
- 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/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/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) + +