diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..77f39858f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,22 @@ +{ + "worktree": { + "baseRef": "fresh" + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "EnterWorktree", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\"" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\"" + } + ] + } + ] + } +} diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md new file mode 100644 index 000000000..226083304 --- /dev/null +++ b/.claude/skills/fix-issue/SKILL.md @@ -0,0 +1,55 @@ +--- +name: fix-issue +description: Implement a GitHub issue with reproduction and verification +allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__* +--- + +The issue number is passed as $1. + +## Instance + +No instance is running yet — start your own, isolated to this worktree: + +1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already). +2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the + background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT` + until it answers (usually ~10-15s). +3. Use `http://localhost:$PORT` as the base URL for Playwright navigation. + +Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from +`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port +discovered mid-session. If those tools are unavailable or point at the wrong +instance, fall back to `curl`/`gh api` for API-level checks, or ask the user +to relaunch with `DOKPLOY_BASE_URL` exported first. + +## Tools + +- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up + state (create a project, an app, an env var) and to verify backend behavior. + Search for the tool you need; they are not all loaded upfront. +- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything + a user would see or click. + +Pick by where the bug lives, not by convenience: + +- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright. + The API returning correct data proves nothing here. +- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP. + A green screenshot proves nothing here. +- Unclear → do both. + +Use the MCP to reach the state you need quickly, then verify in the UI. Do not +click through ten screens to create a project the API can create in one call. + +## Steps + +1. Run `gh issue view $1` and read the full issue, including comments. +2. Reproduce the bug with the appropriate tool. If you cannot reproduce it, + comment on the issue explaining what you tried and STOP. + Do not implement anything. +3. Implement the fix. Keep the change minimal and scoped to the issue. +4. Run `pnpm test`, then re-run the same reproduction from step 2. +5. Only if both pass: commit and run `gh pr create`. The PR description must + include the before/after reproduction steps and reference the issue. + +Never skip step 2. A fix you cannot reproduce and then verify is not a fix. \ No newline at end of file diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 1c228d27e..08d6b8a5b 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -140,6 +140,7 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.get_version.outputs.version }} + npm_version: ${{ steps.get_version.outputs.npm_version }} steps: - name: Checkout uses: actions/checkout@v4 @@ -151,6 +152,7 @@ jobs: run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT - name: Fetch install.sh run: | @@ -164,6 +166,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.get_version.outputs.version }} + target_commitish: ${{ github.sha }} name: ${{ steps.get_version.outputs.version }} generate_release_notes: true draft: false @@ -180,71 +183,82 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.22.0 + + - uses: actions/setup-node@v4 + with: + node-version: 24.4.0 + cache: pnpm + + - name: Generate OpenAPI specification + run: | + pnpm install --frozen-lockfile + pnpm generate:openapi + - name: Sync version to MCP repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo cd /tmp/mcp-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - npm install -g pnpm + cp ${{ github.workspace }}/openapi.json src/generated/openapi.json pnpm install - pnpm run fetch-openapi pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \ --allow-empty git push - echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}" - name: Sync version to CLI repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo cd /tmp/cli-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json cp ${{ github.workspace }}/openapi.json ./openapi.json - npm install -g pnpm pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \ --allow-empty git push - echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}" - name: Sync version to SDK repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo cd /tmp/sdk-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json cp ${{ github.workspace }}/openapi.json ./openapi.json - npm install -g pnpm pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \ --allow-empty git push - echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}" diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index cfddad7b2..48589575f 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup biomeJs uses: biomejs/setup-biome@v2 @@ -19,4 +19,4 @@ jobs: - name: Run Biome formatter run: biome format --write - - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2 + - uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4 diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml new file mode 100644 index 000000000..c85e118d6 --- /dev/null +++ b/.github/workflows/hotfix-cherry-pick.yml @@ -0,0 +1,49 @@ +name: Hotfix Cherry-Pick + +on: + pull_request_target: + types: [closed, labeled] + +concurrency: + group: hotfix-to-main + cancel-in-progress: false + +jobs: + cherry-pick: + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.HOTFIX_PUSH_TOKEN }} + + - name: Cherry-pick fix to main + run: | + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + SHA="${{ github.event.pull_request.merge_commit_sha }}" + if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then + git cherry-pick -x -m 1 "$SHA" + else + git cherry-pick -x "$SHA" + fi + git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]" + git push origin main + + - name: Sync cherry-pick back into canary + run: | + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + git fetch origin canary main + git checkout -B canary origin/canary + if ! git merge origin/main -m "chore: sync hotfix from main into canary [skip ci]"; then + git merge --abort + echo "::error::Could not auto-sync the hotfix into canary (real conflict, not just history divergence). Merge main into canary manually to avoid a conflict on the next release PR." + exit 1 + fi + git push origin canary diff --git a/.github/workflows/hotfix-release.yml b/.github/workflows/hotfix-release.yml new file mode 100644 index 000000000..7e473b3d2 --- /dev/null +++ b/.github/workflows/hotfix-release.yml @@ -0,0 +1,28 @@ +name: Hotfix Release + +on: + workflow_dispatch: + +concurrency: + group: hotfix-to-main + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + token: ${{ secrets.HOTFIX_PUSH_TOKEN }} + + - name: Bump patch version and push + run: | + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + CURRENT=$(node -p "require('./apps/dokploy/package.json').version") + NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}') + sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json + git commit -am "chore: release ${NEW}" + git push origin main diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2ad24fc0c..227022a90 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,9 +14,9 @@ jobs: matrix: job: [build, test, typecheck] steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v5 with: node-version: 24.4.0 cache: "pnpm" diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml new file mode 100644 index 000000000..f73b7276e --- /dev/null +++ b/.github/workflows/upgrade-integration-test.yml @@ -0,0 +1,437 @@ +# Upgrade Integration Test +# +# Tests that Dokploy can upgrade from version A to version B while keeping +# user projects (Postgres, MongoDB, two web apps, one static site) alive. +# +# Generates upgrade pairs: each stable tag in [floor_version, target_version) +# is paired with target_version. If target_version is empty, the highest tag +# available on Docker Hub is used as the target. +# +# Only triggered manually to avoid burning Actions minutes. + +name: Upgrade Integration Test + +on: + workflow_dispatch: + inputs: + floor_version: + description: 'Oldest version tag to include in pairs (e.g. v0.29.4)' + required: false + default: 'v0.29.4' + target_version: + description: 'Target version to upgrade to (version B). Leave empty to use the highest available.' + required: false + default: '' + +env: + DOKPLOY_IMAGE: dokploy/dokploy + DOKPLOY_SERVICE: dokploy + DOKPLOY_PORT: 3000 + +# ────────────────────────────────────────────────────────────────────────────── +jobs: + + # ── 1. Build the matrix ───────────────────────────────────────────────────── + build-matrix: + name: Build upgrade pair matrix + runs-on: ubuntu-latest + outputs: + pairs: ${{ steps.pairs.outputs.pairs }} + + steps: + - name: Generate pairs + id: pairs + env: + FLOOR: ${{ inputs.floor_version }} + TARGET: ${{ inputs.target_version }} + run: | + set -euo pipefail + + # semver "a >= b" comparison helper (vX.Y.Z, strips leading v) + ge() { + [ "$(printf '%s\n%s\n' "${1#v}" "${2#v}" | sort -V | tail -n1)" = "${1#v}" ] + } + + # Fetch all semver tags from Docker Hub (dokploy/dokploy) + ALL_TAGS=$(curl -fsSL \ + "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags?page_size=100" | \ + jq -r '.results[].name' | \ + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \ + sort -V) + + echo "All tags found:" + echo "$ALL_TAGS" + + # Target version (version B): explicit input, else highest available. + if [ -n "$TARGET" ]; then + TO="$TARGET" + echo "Using user-supplied target version: $TO" + else + TO=$(echo "$ALL_TAGS" | tail -n1) + echo "No target supplied; using highest available: $TO" + fi + + # Floor semver components + FLOOR_CLEAN="${FLOOR#v}" + IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN" + + # Each tag in [FLOOR, TO) → TO + PAIRS='[]' + while read -r TAG; do + [ -z "$TAG" ] && continue + # skip tags above-or-equal to the target (incl. the target itself) + ge "$TAG" "$TO" && continue + TAG_CLEAN="${TAG#v}" + IFS='.' read -r T_MAJ T_MIN T_PAT <<< "$TAG_CLEAN" + if [ "$T_MAJ" -gt "$F_MAJ" ] || \ + { [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -gt "$F_MIN" ]; } || \ + { [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -eq "$F_MIN" ] && [ "$T_PAT" -ge "$F_PAT" ]; }; then + PAIRS=$(echo "$PAIRS" | jq -c \ + --arg f "$TAG" --arg t "$TO" \ + '. + [{"from":$f,"to":$t}]') + fi + done <<< "$ALL_TAGS" + + COUNT=$(echo "$PAIRS" | jq 'length') + echo "Total pairs: $COUNT" + echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"' + + echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT" + + + # ── 2. Run one upgrade test per pair ──────────────────────────────────────── + upgrade-test: + name: "${{ matrix.pair.from }} → ${{ matrix.pair.to }}" + needs: build-matrix + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + pair: ${{ fromJSON(needs.build-matrix.outputs.pairs) }} + + steps: + + # ── Environment setup ────────────────────────────────────────────────── + - name: Free disk space + run: | + sudo rm -rf \ + /usr/share/dotnet /opt/ghc /usr/local/share/boost \ + "$AGENT_TOOLSDIRECTORY" /usr/local/lib/android \ + /usr/local/share/chromium /opt/hostedtoolcache + docker system prune -af --volumes + df -h + + # ── Install Dokploy directly at VERSION A ────────────────────────────── + # install.sh: + # • requires root (run via sudo bash) + # • respects DOKPLOY_VERSION → installs that tag directly + # (so we don't need a separate "downgrade" step that would risk + # running B's migrations on A's expected schema) + # • respects ADVERTISE_ADDR → skip the external IP lookup + # • initializes Docker Swarm + dokploy-network itself + - name: Install Dokploy at VERSION A (${{ matrix.pair.from }}) + run: | + curl -fsSL https://dokploy.com/install.sh -o /tmp/install.sh + chmod +x /tmp/install.sh + sudo -E env \ + DOKPLOY_VERSION="${{ matrix.pair.from }}" \ + ADVERTISE_ADDR="127.0.0.1" \ + bash /tmp/install.sh + + - name: Wait for dokploy service to converge on ${{ matrix.pair.from }} + run: | + echo "Waiting for 'dokploy' Swarm service to reach 1/1..." + timeout 240 bash -c ' + until docker service ls --filter name=dokploy \ + --format "{{.Name}} {{.Replicas}}" \ + | grep "^dokploy " | grep -q " 1/1"; do + sleep 4 + done + ' + docker service ls + echo "✅ Service running on ${{ matrix.pair.from }}" + + - name: Wait for Dokploy API to accept requests + run: | + timeout 180 bash -c ' + until curl -sf -o /dev/null \ + "http://localhost:${{ env.DOKPLOY_PORT }}"; do + sleep 3 + done + ' + echo "✅ Dokploy API is up" + + # ── Bootstrap admin user ─────────────────────────────────────────────── + - name: Register first admin user + id: auth + run: | + COOKIE_JAR="$RUNNER_TEMP/dokploy-cookies.txt" + : > "$COOKIE_JAR" + chmod 600 "$COOKIE_JAR" + echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT" + + # better-auth: sign-up/email (allowed only before any owner exists) + set -x + curl -sS -i -X POST \ + "http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-up/email" \ + -H "Content-Type: application/json" \ + -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + -d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \ + | tee /tmp/signup.out + set +x + + # Verify we got a session cookie + if ! grep -qE '(better-auth|auth)\.session' "$COOKIE_JAR"; then + echo "⚠️ No session cookie matched expected name; jar contents:" + cat "$COOKIE_JAR" + fi + + # ── Create test resources ────────────────────────────────────────────── + - name: Create project, databases and applications + id: create + env: + BASE: "http://localhost:${{ env.DOKPLOY_PORT }}" + COOKIE: ${{ steps.auth.outputs.cookie_jar }} + run: | + set -euo pipefail + + # --- tRPC POST helper --- + trpc_mut() { + curl -sf -X POST "$BASE/api/trpc/$1" \ + -H "Content-Type: application/json" \ + -b "$COOKIE" -c "$COOKIE" \ + -d "{\"json\":$2}" + } + + # --- Project --- + PROJECT=$(trpc_mut project.create \ + '{"name":"ci-upgrade-test","description":"CI upgrade integration test"}') + echo "project.create → $PROJECT" + + # project.create in v0.29+ returns nested {project:{projectId}, environment:{environmentId}} + PROJECT_ID=$(echo "$PROJECT" | jq -r \ + '.result.data.json.project.projectId // .result.data.json.projectId // empty') + ENV_ID=$(echo "$PROJECT" | jq -r \ + '.result.data.json.environment.environmentId // empty') + + if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then + echo "❌ Could not extract projectId from project.create response:" + echo "$PROJECT" | jq . + exit 1 + fi + if [ -z "$ENV_ID" ] || [ "$ENV_ID" = "null" ]; then + echo "❌ Could not extract environmentId — this version may not support environments." + echo " project.create response: $PROJECT" + exit 1 + fi + + echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT" + echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT" + echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=$ENV_ID" + + # --- PostgreSQL 15 --- + PG=$(trpc_mut postgres.create \ + "{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\ + \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ + \"databasePassword\":\"CiPg1pass\",\ + \"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}") + echo "postgres.create → $PG" + PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId') + # deploy (not start) — deploy creates the Swarm service; start only + # scales an already-deployed service and 500s on a fresh resource. + trpc_mut postgres.deploy "{\"postgresId\":\"$PG_ID\"}" > /dev/null + echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT" + + # --- MongoDB 7.0 --- + MG=$(trpc_mut mongo.create \ + "{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\ + \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ + \"databasePassword\":\"CiMg1pass\",\ + \"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}") + echo "mongo.create → $MG" + MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId') + trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null + echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT" + + # --- Docker-image application helper --- + make_app() { + local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3 + APP=$(trpc_mut application.create \ + "{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",\"environmentId\":\"$ENV_ID\"}") + APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId') + trpc_mut application.saveDockerProvider \ + "{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\ + \"username\":\"\",\"password\":\"\",\"registryUrl\":\"\"}" \ + > /dev/null + trpc_mut application.deploy "{\"applicationId\":\"$APP_ID\"}" \ + > /dev/null + echo "$APP_ID" + } + + # Static site (nginx) + APP_STATIC=$(make_app "ci-static" "ci-static-app" "nginx:alpine") + echo "app_static_id=$APP_STATIC" >> "$GITHUB_OUTPUT" + + # Node.js hello-world (echo server image — small, no args needed) + APP_NODE=$(make_app "ci-node" "ci-node-app" "ealen/echo-server:latest") + echo "app_node_id=$APP_NODE" >> "$GITHUB_OUTPUT" + + # Go HTTP server (traefik/whoami is a tiny Go binary, port 80) + APP_GO=$(make_app "ci-go" "ci-go-app" "traefik/whoami:latest") + echo "app_go_id=$APP_GO" >> "$GITHUB_OUTPUT" + + echo "✅ All resources created" + + # ── Pre-upgrade health check ─────────────────────────────────────────── + - name: Wait for all services → 'done' (pre-upgrade) + env: + BASE: "http://localhost:${{ env.DOKPLOY_PORT }}" + COOKIE: ${{ steps.auth.outputs.cookie_jar }} + PG_ID: ${{ steps.create.outputs.pg_id }} + MG_ID: ${{ steps.create.outputs.mg_id }} + APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }} + APP_NODE_ID: ${{ steps.create.outputs.app_node_id }} + APP_GO_ID: ${{ steps.create.outputs.app_go_id }} + run: | + wait_done() { + local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5 + echo "Waiting for $NAME to reach 'done'..." + timeout 360 bash -c " + until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \ + --data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \ + -b '$COOKIE' | \ + jq -r '.result.data.json.$STATUS_KEY // \"unknown\"')\" \ + = 'done' ]; do + sleep 5 + done + " + echo "✅ $NAME is done" + } + + wait_done postgres postgres.one postgresId "$PG_ID" applicationStatus + wait_done mongo mongo.one mongoId "$MG_ID" applicationStatus + wait_done static application.one applicationId "$APP_STATIC_ID" applicationStatus + wait_done node-app application.one applicationId "$APP_NODE_ID" applicationStatus + wait_done go-app application.one applicationId "$APP_GO_ID" applicationStatus + + - name: Assert Docker Swarm services healthy (pre-upgrade) + run: | + echo "=== docker service ls ===" + docker service ls + + FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \ + grep -E '^ci-' | grep -v ' 1/1' || true) + if [ -n "$FAIL" ]; then + echo "❌ User services not healthy before upgrade:" + echo "$FAIL" + exit 1 + fi + echo "✅ All user services healthy before upgrade" + + # ── Upgrade ──────────────────────────────────────────────────────────── + - name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }}) + run: | + docker service update \ + --image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \ + --force \ + "${{ env.DOKPLOY_SERVICE }}" + + echo "Waiting for service to converge on ${{ matrix.pair.to }}..." + timeout 240 bash -c ' + until ! docker service inspect dokploy \ + --format "{{.UpdateStatus.State}}" 2>/dev/null \ + | grep -q "^updating$"; do + sleep 4 + done + until docker service ls --filter name=dokploy \ + --format "{{.Name}} {{.Replicas}}" \ + | grep "^dokploy " | grep -q " 1/1"; do + sleep 4 + done + ' + echo "✅ Service running on ${{ matrix.pair.to }}" + + - name: Wait for Dokploy API to respond post-upgrade + run: | + timeout 180 bash -c ' + until curl -sf -o /dev/null \ + "http://localhost:${{ env.DOKPLOY_PORT }}"; do + sleep 3 + done + ' + echo "✅ Dokploy API is up after upgrade" + + # ── Post-upgrade health check ────────────────────────────────────────── + - name: Verify all services still healthy (post-upgrade) + env: + BASE: "http://localhost:${{ env.DOKPLOY_PORT }}" + COOKIE: ${{ steps.auth.outputs.cookie_jar }} + PG_ID: ${{ steps.create.outputs.pg_id }} + MG_ID: ${{ steps.create.outputs.mg_id }} + APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }} + APP_NODE_ID: ${{ steps.create.outputs.app_node_id }} + APP_GO_ID: ${{ steps.create.outputs.app_go_id }} + run: | + check_status() { + local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5 + STATUS=$(curl -sf -G "$BASE/api/trpc/$ENDPOINT" \ + --data-urlencode "input={\"json\":{\"$ID_KEY\":\"$ID\"}}" \ + -b "$COOKIE" | \ + jq -r ".result.data.json.$STATUS_KEY // \"unknown\"") + if [ "$STATUS" != "done" ]; then + echo "❌ $NAME status after upgrade: $STATUS" + return 1 + fi + echo "✅ $NAME: $STATUS" + } + + check_status postgres postgres.one postgresId "$PG_ID" applicationStatus + check_status mongo mongo.one mongoId "$MG_ID" applicationStatus + check_status static application.one applicationId "$APP_STATIC_ID" applicationStatus + check_status node-app application.one applicationId "$APP_NODE_ID" applicationStatus + check_status go-app application.one applicationId "$APP_GO_ID" applicationStatus + + echo "=== docker service ls (post-upgrade) ===" + docker service ls + + FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \ + grep -E '^ci-' | grep -v ' 1/1' || true) + if [ -n "$FAIL" ]; then + echo "❌ User services not healthy after upgrade:" + echo "$FAIL" + exit 1 + fi + echo "✅ All services healthy after upgrade to ${{ matrix.pair.to }}" + + # ── Diagnostics on failure ───────────────────────────────────────────── + - name: Dump state on failure + if: failure() + run: | + echo "=== docker service ls ===" && docker service ls || true + echo "=== dokploy service tasks ===" && \ + docker service ps "${{ env.DOKPLOY_SERVICE }}" --no-trunc || true + echo "=== dokploy logs (last 200) ===" && \ + docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 200 2>&1 || true + echo "=== install.sh tail ===" && \ + tail -100 /tmp/install.sh 2>&1 || true + echo "=== signup response ===" && \ + cat /tmp/signup.out 2>&1 || true + echo "=== disk usage ===" && df -h + + # ── Job summary ──────────────────────────────────────────────────────── + - name: Write job summary + if: always() + run: | + STATUS="${{ job.status }}" + ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌" + cat >> "$GITHUB_STEP_SUMMARY" < { + it("rejects an empty name", () => { + const result = apiKeyNameSchema.safeParse(""); + expect(result.success).toBe(false); + }); + + it("accepts a name at the maximum length", () => { + const name = "a".repeat(API_KEY_NAME_MAX_LENGTH); + const result = apiKeyNameSchema.safeParse(name); + expect(result.success).toBe(true); + }); + + it("rejects a name over the maximum length instead of passing it to better-auth", () => { + const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1); + const result = apiKeyNameSchema.safeParse(name); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + `Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`, + ); + } + }); +}); diff --git a/apps/dokploy/__test__/api/session-management.test.ts b/apps/dokploy/__test__/api/session-management.test.ts new file mode 100644 index 000000000..48f7eb257 --- /dev/null +++ b/apps/dokploy/__test__/api/session-management.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +const revokeSessionSchema = z.object({ + sessionId: z.string(), +}); + +describe("revokeSession input validation", () => { + it("accepts a valid session id", () => { + const result = revokeSessionSchema.safeParse({ sessionId: "abc123" }); + expect(result.success).toBe(true); + }); + + it("rejects missing sessionId", () => { + const result = revokeSessionSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it("rejects non-string sessionId", () => { + const result = revokeSessionSchema.safeParse({ sessionId: 123 }); + expect(result.success).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/backups/db-backup-restore-injection.test.ts b/apps/dokploy/__test__/backups/db-backup-restore-injection.test.ts new file mode 100644 index 000000000..d48644c95 --- /dev/null +++ b/apps/dokploy/__test__/backups/db-backup-restore-injection.test.ts @@ -0,0 +1,106 @@ +import { execSync } from "node:child_process"; +import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs"; +import { + getLibsqlBackupCommand, + getMariadbBackupCommand, + getMongoBackupCommand, + getMysqlBackupCommand, + getPostgresBackupCommand, +} from "@dokploy/server/utils/backups/utils"; +import { + getMariadbRestoreCommand, + getMongoRestoreCommand, + getMysqlRestoreCommand, + getPostgresRestoreCommand, +} from "@dokploy/server/utils/restore/utils"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID, +// exports the -e VAR=val pairs, and runs the inner `sh -c +