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" < + spawnSync("bash", ["-c", command], { + encoding: "utf8", + }); + +const outputLines = (stdout: string) => + stdout + .trim() + .split("\n") + .filter((line) => line.length > 0); + +describe("createRestartSafeBackupCommand", () => { + it("restarts the service and preserves the backup error status", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"; exit 23', + startCommand: 'echo "start"', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(23); + expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]); + }); + + it("preserves the backup error status when the restart also fails", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"; exit 23', + startCommand: 'echo "start"; exit 17', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(23); + expect(outputLines(result.stdout)).toEqual([ + "stop", + "backup", + "start", + "Service restart also failed with exit code 17", + ]); + }); + + it("returns the restart error status when the backup succeeds", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"', + startCommand: 'echo "start"; exit 17', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(17); + expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]); + }); + + it("uploads only after a successful backup and service restart", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"', + startCommand: 'echo "start"', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(0); + expect(outputLines(result.stdout)).toEqual([ + "stop", + "backup", + "start", + "upload", + ]); + }); +}); diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts index e25bd425e..6b61c97d7 100644 --- a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ queueAdd: vi.fn(), verify: vi.fn(), shouldDeploy: vi.fn(), + createPreviewDeployment: vi.fn(), + findPreviewDeploymentByApplicationId: vi.fn(), })); vi.mock("drizzle-orm", () => ({ @@ -64,10 +66,11 @@ vi.mock("@dokploy/server", () => ({ IS_CLOUD: false, shouldDeploy: mocks.shouldDeploy, checkUserRepositoryPermissions: vi.fn(), - createPreviewDeployment: vi.fn(), + createPreviewDeployment: mocks.createPreviewDeployment, createSecurityBlockedComment: vi.fn(), findGithubById: vi.fn(), - findPreviewDeploymentByApplicationId: vi.fn(), + findPreviewDeploymentByApplicationId: + mocks.findPreviewDeploymentByApplicationId, findPreviewDeploymentsByPullRequestId: vi.fn(), getBitbucketHeaders: vi.fn(() => ({})), removePreviewDeployment: vi.fn(), @@ -321,3 +324,157 @@ describe("GitHub app webhook auto-deploy", () => { expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" }); }); }); + +describe("GitHub app webhook preview deployments", () => { + const createApplication = ( + overrides: Record = {}, + ): Record => ({ + applicationId: "application-id", + name: "my-app", + serverId: null, + previewLabels: [], + previewLimit: 3, + previewDeployments: [], + previewRequireCollaboratorPermissions: false, + ...overrides, + }); + + const createPreviewDeployments = (total: number) => + Array.from({ length: total }, (_, index) => ({ + previewDeploymentId: `existing-preview-${index}`, + })); + + const createPullRequestRequest = (action: string) => + ({ + headers: { + "x-hub-signature-256": "sha256=test-signature", + "x-github-event": "pull_request", + }, + body: { + installation: { + id: 12345, + }, + action, + pull_request: { + id: 987, + number: 42, + title: "feat: add preview", + html_url: "https://github.com/agentHits/dokploy/pull/42", + labels: [], + user: { + login: "agentHits", + }, + head: { + ref: "feature", + sha: "abc123", + }, + base: { + ref: "main", + }, + }, + repository: { + name: "dokploy", + owner: { + login: "agentHits", + }, + }, + }, + }) as unknown as NextApiRequest; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.githubFindFirst.mockResolvedValue({ + githubId: "github-provider-id", + githubInstallationId: 12345, + githubWebhookSecret: "webhook-secret", + }); + mocks.verify.mockResolvedValue(true); + mocks.queueAdd.mockResolvedValue({ id: "job-id" }); + mocks.createPreviewDeployment.mockResolvedValue({ + previewDeploymentId: "new-preview-id", + }); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined); + }); + + it("redeploys an existing preview even when the limit is reached", async () => { + mocks.applicationsFindMany.mockResolvedValue([ + createApplication({ + previewLimit: 2, + previewDeployments: createPreviewDeployments(3), + }), + ]); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({ + previewDeploymentId: "existing-preview-0", + }); + const res = createResponse(); + + await handler(createPullRequestRequest("synchronize"), res); + + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationId: "application-id", + applicationType: "application-preview", + previewDeploymentId: "existing-preview-0", + type: "deploy", + }), + expect.objectContaining({ + removeOnComplete: true, + removeOnFail: true, + }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("does not create a new preview once the limit is reached", async () => { + mocks.applicationsFindMany.mockResolvedValue([ + createApplication({ + previewLimit: 2, + previewDeployments: createPreviewDeployments(2), + }), + ]); + const res = createResponse(); + + await handler(createPullRequestRequest("opened"), res); + + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("falls back to the default limit when none is configured", async () => { + mocks.applicationsFindMany.mockResolvedValue([ + createApplication({ + previewLimit: null, + previewDeployments: createPreviewDeployments(2), + }), + ]); + const res = createResponse(); + + await handler(createPullRequestRequest("opened"), res); + + expect(mocks.createPreviewDeployment).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: "application-id", + branch: "feature", + pullRequestId: 987, + pullRequestNumber: 42, + }), + ); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationId: "application-id", + applicationType: "application-preview", + previewDeploymentId: "new-preview-id", + type: "deploy", + }), + expect.objectContaining({ + removeOnComplete: true, + removeOnFail: true, + }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index c6cb473e0..20570b07a 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -26,6 +26,11 @@ interface Props { serviceId?: string; } +// Sentinel the container-picker views fall back to before a real container +// is selected/auto-selected — querying logs for it just surfaces Docker's +// raw "No such container: select-a-container" daemon error. +const PLACEHOLDER_CONTAINER_ID = "select-a-container"; + export const priorities = [ { label: "Info", @@ -55,13 +60,16 @@ export const DockerLogsId: React.FC = ({ runType, serviceId, }) => { + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + const { data } = api.docker.getConfig.useQuery( { containerId, serverId: serverId ?? undefined, }, { - enabled: !!containerId, + enabled: hasContainer, }, ); @@ -134,7 +142,7 @@ export const DockerLogsId: React.FC = ({ }; useEffect(() => { - if (!containerId) return; + if (!hasContainer) return; let isCurrentConnection = true; let noDataTimeout: NodeJS.Timeout; @@ -425,10 +433,15 @@ export const DockerLogsId: React.FC = ({
- ) : ( + ) : hasContainer ? (
No logs found
+ ) : ( +
+ Select a container above to view its logs. If none are listed, + make sure the service is deployed and running. +
)} diff --git a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx index 75ddecbe9..2d79b85cc 100644 --- a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx +++ b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx @@ -15,6 +15,10 @@ interface Props { serviceId?: string; } +// Sentinel the container-picker modal renders with before a real container +// is selected/auto-selected — never worth opening a connection for. +const PLACEHOLDER_CONTAINER_ID = "select-a-container"; + export const DockerTerminal: React.FC = ({ id, containerId, @@ -25,59 +29,105 @@ export const DockerTerminal: React.FC = ({ const [activeWay, setActiveWay] = React.useState("bash"); const { resolvedTheme } = useTheme(); useEffect(() => { - const container = document.getElementById(id); - if (container) { - container.innerHTML = ""; + if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) { + return; } - const term = new Terminal({ - cursorBlink: true, - lineHeight: 1.4, - convertEol: true, - theme: { - cursor: resolvedTheme === "light" ? "#000000" : "transparent", - background: "rgba(0, 0, 0, 0)", - foreground: "currentColor", - }, + + let cancelled = false; + let term: Terminal | null = null; + let ws: WebSocket | null = null; + let resizeObserver: ResizeObserver | null = null; + + // Deferred a frame so React StrictMode's dev-only phantom mount+cleanup + // (which runs synchronously, before anything paints) never creates a + // terminal in the first place — dodges a known xterm.js dispose race + // (xtermjs/xterm.js#5011) where a deferred internal callback outlives + // term.dispose() and reads renderer state that's already gone. + const frame = requestAnimationFrame(() => { + if (cancelled) return; + + const container = document.getElementById(id); + if (container) { + container.innerHTML = ""; + } + term = new Terminal({ + cursorBlink: true, + lineHeight: 1.4, + convertEol: true, + theme: { + cursor: resolvedTheme === "light" ? "#000000" : "transparent", + background: "rgba(0, 0, 0, 0)", + foreground: "currentColor", + }, + }); + const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + // @ts-expect-error + term.open(termRef.current); + term.loadAddon(addonFit); + addonFit.fit(); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}&cols=${term.cols}&rows=${term.rows}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; + + ws = new WebSocket(wsUrl); + const addonAttach = new AttachAddon(ws); + term.loadAddon(addonAttach); + + const sendResize = (cols: number, rows: number) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } }); - const addonFit = new FitAddon(); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; - - const ws = new WebSocket(wsUrl); - - const addonAttach = new AttachAddon(ws); - const clipboardAddon = new ClipboardAddon(); - term.loadAddon(clipboardAddon); - fixMacOsAltKeys(term); - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); - term.loadAddon(addonAttach); - addonFit.fit(); return () => { - ws.readyState === WebSocket.OPEN && ws.close(); - term.dispose(); + cancelled = true; + cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + term?.dispose(); }; }, [containerId, activeWay, id]); + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + return (
-
- - Select way to connect to {containerId} - - - - Bash - /bin/sh - - -
-
-
-
+ {hasContainer && ( +
+ + Select way to connect to {containerId} + + + + Bash + /bin/sh + + +
+ )} + {hasContainer ? ( +
+
+
+ ) : ( +
+ Select a container above to open a terminal. If none are listed, make + sure the service is deployed and running. +
+ )}
); }; diff --git a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx index 343e0f93b..bb26ce863 100644 --- a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx +++ b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx @@ -53,9 +53,14 @@ type Tag = z.infer; interface HandleTagProps { tagId?: string; + /** + * Called with the new tag's id after it is created, so callers embedding + * this dialog (e.g. the tag selector) can select the tag right away. + */ + onCreated?: (tagId: string) => void; } -export const HandleTag = ({ tagId }: HandleTagProps) => { +export const HandleTag = ({ tagId, onCreated }: HandleTagProps) => { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const colorInputRef = useRef(null); @@ -101,8 +106,11 @@ export const HandleTag = ({ tagId }: HandleTagProps) => { color: data.color, tagId: tagId || "", }) - .then(async () => { + .then(async (result) => { await utils.tag.all.invalidate(); + if (!tagId && result?.tagId) { + onCreated?.(result.tagId); + } toast.success(tagId ? "Tag Updated" : "Tag Created"); setIsOpen(false); form.reset(); @@ -139,9 +147,18 @@ export const HandleTag = ({ tagId }: HandleTagProps) => { {isError && {error?.message}}
+ {/* + * This dialog can be rendered inside another form (e.g. the tag + * selector in the project dialog). React propagates events through the + * React tree, not the DOM tree, so without stopPropagation submitting + * this form would also submit the outer one. + */} { + e.stopPropagation(); + form.handleSubmit(onSubmit)(e); + }} className="grid w-full gap-4" > = ({ id, serverId }) => { }); const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + + // @ts-ignore + term.open(termRef.current); + // @ts-ignore + term.loadAddon(addonFit); + addonFit.fit(); const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const urlParams = new URLSearchParams(); urlParams.set("serverId", serverId); + urlParams.set("cols", term.cols.toString()); + urlParams.set("rows", term.rows.toString()); if (serverId === "local") { const { port, username } = getLocalServerData(); @@ -57,17 +68,22 @@ export const Terminal: React.FC = ({ id, serverId }) => { const ws = new WebSocket(wsUrl); const addonAttach = new AttachAddon(ws); - const clipboardAddon = new ClipboardAddon(); - term.loadAddon(clipboardAddon); - fixMacOsAltKeys(term); - - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); term.loadAddon(addonAttach); - addonFit.fit(); + + const sendResize = (cols: number, rows: number) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + const resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } + return () => { + resizeObserver.disconnect(); ws.readyState === WebSocket.OPEN && ws.close(); }; }, [id, serverId]); diff --git a/apps/dokploy/components/shared/tag-selector.tsx b/apps/dokploy/components/shared/tag-selector.tsx index 036173ded..ab2d52504 100644 --- a/apps/dokploy/components/shared/tag-selector.tsx +++ b/apps/dokploy/components/shared/tag-selector.tsx @@ -147,7 +147,13 @@ export function TagSelector({ })}
- + { + if (!selectedTags.includes(tagId)) { + onTagsChange([...selectedTags, tagId]); + } + }} + />
diff --git a/apps/dokploy/components/ui/switch.tsx b/apps/dokploy/components/ui/switch.tsx index 9080b035c..d9962a815 100644 --- a/apps/dokploy/components/ui/switch.tsx +++ b/apps/dokploy/components/ui/switch.tsx @@ -22,7 +22,7 @@ function Switch({ > ); diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 900bc7a09..40b9ca156 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.30.0", + "version": "v0.30.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index 4fc75fc50..2273ac3bc 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -484,10 +484,6 @@ export default async function handler( if (!hasLabel) continue; } - const previewLimit = app?.previewLimit || 0; - if (app?.previewDeployments?.length > previewLimit) { - continue; - } const previewDeploymentResult = await findPreviewDeploymentByApplicationId(app.applicationId, prId); @@ -495,6 +491,15 @@ export default async function handler( previewDeploymentResult?.previewDeploymentId || ""; if (!previewDeploymentResult && shouldCreateDeployment) { + // The limit only applies to new previews, existing ones must + // still be redeployed when the pull request is updated. + const previewLimit = app?.previewLimit ?? 3; + if ((app?.previewDeployments?.length ?? 0) >= previewLimit) { + console.warn( + `⚠️ Preview deployment limit (${previewLimit}) reached for ${app.name}, skipping preview for pull request #${prNumber}`, + ); + continue; + } const previewDeployment = await createPreviewDeployment({ applicationId: app.applicationId as string, branch: prBranch, diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index 4366e7393..98932f8af 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -11,7 +11,7 @@ import { findProjectById, getAccessibleServerIds, getContainerLogs, - getServiceContainerCommand, + getServiceContainer, getWebServerSettings, IS_CLOUD, rebuildDatabase, @@ -410,17 +410,17 @@ export const mariadbRouter = createTRPCRouter({ const maria = await findMariadbById(mariadbId); const { appName, serverId, databaseUser, databaseRootPassword } = maria; - const containerCmd = getServiceContainerCommand(appName); + const container = await getServiceContainer(appName, serverId); + if (!container) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `No running container found for ${appName}`, + }); + } + const targetUser = type === "root" ? "root" : databaseUser; - const command = ` - CONTAINER_ID=$(${containerCmd}) - if [ -z "$CONTAINER_ID" ]; then - echo "No running container found for ${appName}" >&2 - exit 1 - fi - docker exec "$CONTAINER_ID" mariadb -u root -p'${databaseRootPassword}' -e "ALTER USER '${targetUser}'@'%' IDENTIFIED BY '${password}'; FLUSH PRIVILEGES;" - `; + const command = `docker exec ${container.Id} mariadb -u root -p'${databaseRootPassword}' -e "ALTER USER '${targetUser}'@'%' IDENTIFIED BY '${password}'; FLUSH PRIVILEGES;"`; await db.transaction(async (tx) => { const setData = diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index de38435b4..42b410785 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -11,7 +11,7 @@ import { findProjectById, getAccessibleServerIds, getContainerLogs, - getServiceContainerCommand, + getServiceContainer, getWebServerSettings, IS_CLOUD, rebuildDatabase, @@ -431,15 +431,15 @@ export const mongoRouter = createTRPCRouter({ const mongo = await findMongoById(mongoId); const { appName, serverId, databaseUser, databasePassword } = mongo; - const containerCmd = getServiceContainerCommand(appName); - const command = ` - CONTAINER_ID=$(${containerCmd}) - if [ -z "$CONTAINER_ID" ]; then - echo "No running container found for ${appName}" >&2 - exit 1 - fi - docker exec "$CONTAINER_ID" mongosh -u '${databaseUser}' -p '${databasePassword}' --authenticationDatabase admin --eval "db.getSiblingDB('admin').changeUserPassword('${databaseUser}', '${password}')" - `; + const container = await getServiceContainer(appName, serverId); + if (!container) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `No running container found for ${appName}`, + }); + } + + const command = `docker exec ${container.Id} mongosh -u '${databaseUser}' -p '${databasePassword}' --authenticationDatabase admin --eval "db.getSiblingDB('admin').changeUserPassword('${databaseUser}', '${password}')"`; await db.transaction(async (tx) => { await tx diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index 70c40520a..c315ec5d4 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -11,7 +11,7 @@ import { findProjectById, getAccessibleServerIds, getContainerLogs, - getServiceContainerCommand, + getServiceContainer, getWebServerSettings, IS_CLOUD, rebuildDatabase, @@ -428,17 +428,17 @@ export const mysqlRouter = createTRPCRouter({ const my = await findMySqlById(mysqlId); const { appName, serverId, databaseUser, databaseRootPassword } = my; - const containerCmd = getServiceContainerCommand(appName); + const container = await getServiceContainer(appName, serverId); + if (!container) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `No running container found for ${appName}`, + }); + } + const targetUser = type === "root" ? "root" : databaseUser; - const command = ` - CONTAINER_ID=$(${containerCmd}) - if [ -z "$CONTAINER_ID" ]; then - echo "No running container found for ${appName}" >&2 - exit 1 - fi - docker exec "$CONTAINER_ID" mysql -u root -p'${databaseRootPassword}' -e "ALTER USER '${targetUser}'@'%' IDENTIFIED BY '${password}'; FLUSH PRIVILEGES;" - `; + const command = `docker exec ${container.Id} mysql -u root -p'${databaseRootPassword}' -e "ALTER USER '${targetUser}'@'%' IDENTIFIED BY '${password}'; FLUSH PRIVILEGES;"`; await db.transaction(async (tx) => { const setData = diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 9b1b3330a..97e398123 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -12,7 +12,7 @@ import { getAccessibleServerIds, getContainerLogs, getMountPath, - getServiceContainerCommand, + getServiceContainer, getWebServerSettings, IS_CLOUD, rebuildDatabase, @@ -437,15 +437,15 @@ export const postgresRouter = createTRPCRouter({ const pg = await findPostgresById(postgresId); const { appName, serverId, databaseUser } = pg; - const containerCmd = getServiceContainerCommand(appName); - const command = ` - CONTAINER_ID=$(${containerCmd}) - if [ -z "$CONTAINER_ID" ]; then - echo "No running container found for ${appName}" >&2 - exit 1 - fi - docker exec "$CONTAINER_ID" psql -U ${databaseUser} -c "ALTER USER \\"${databaseUser}\\" WITH PASSWORD '${password}';" - `; + const container = await getServiceContainer(appName, serverId); + if (!container) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `No running container found for ${appName}`, + }); + } + + const command = `docker exec ${container.Id} psql -U ${databaseUser} -d postgres -c "ALTER USER \\"${databaseUser}\\" WITH PASSWORD '${password}';"`; await db.transaction(async (tx) => { await tx diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index 747cce4a3..fb0d8301c 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -10,7 +10,7 @@ import { findRedisById, getAccessibleServerIds, getContainerLogs, - getServiceContainerCommand, + getServiceContainer, getWebServerSettings, IS_CLOUD, rebuildDatabase, @@ -418,15 +418,15 @@ export const redisRouter = createTRPCRouter({ const rd = await findRedisById(redisId); const { appName, serverId, databasePassword } = rd; - const containerCmd = getServiceContainerCommand(appName); - const command = ` - CONTAINER_ID=$(${containerCmd}) - if [ -z "$CONTAINER_ID" ]; then - echo "No running container found for ${appName}" >&2 - exit 1 - fi - docker exec "$CONTAINER_ID" redis-cli -a '${databasePassword}' CONFIG SET requirepass '${password}' - `; + const container = await getServiceContainer(appName, serverId); + if (!container) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `No running container found for ${appName}`, + }); + } + + const command = `docker exec ${container.Id} redis-cli -a '${databasePassword}' CONFIG SET requirepass '${password}'`; await db.transaction(async (tx) => { await tx diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index 64e95c3ae..bd731476f 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -4,7 +4,12 @@ import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; import { canAccessDockerOverWss } from "./authorize"; -import { isValidContainerId, isValidShell } from "./utils"; +import { + isValidContainerId, + isValidShell, + parseResizeMessage, + parseTerminalSize, +} from "./utils"; export const setupDockerContainerTerminalWebSocketServer = ( server: http.Server, @@ -34,6 +39,10 @@ export const setupDockerContainerTerminalWebSocketServer = ( const activeWay = url.searchParams.get("activeWay"); const serverId = url.searchParams.get("serverId"); const serviceId = url.searchParams.get("serviceId"); + const { cols, rows } = parseTerminalSize( + url.searchParams.get("cols"), + url.searchParams.get("rows"), + ); const { user, session } = await validateRequest(req); if (!containerId) { @@ -92,7 +101,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( containerId, shell, ].join(" "); - conn.exec(dockerCommand, { pty: true }, (err, stream) => { + conn.exec(dockerCommand, { pty: { cols, rows } }, (err, stream) => { if (err) { console.error("SSH exec error:", err); ws.close(); @@ -123,7 +132,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( } else { command = message; } - stream.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + stream.setWindow(resize.rows, resize.cols, 0, 0); + return; + } + stream.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; @@ -161,7 +176,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( const ptyProcess = spawn( "docker", ["exec", "-it", "-w", "/", containerId, shell], - {}, + { cols, rows }, ); ptyProcess.onData((data) => { @@ -178,7 +193,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( } else { command = message; } - ptyProcess.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + ptyProcess.resize(resize.cols, resize.rows); + return; + } + ptyProcess.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts index 90c36855e..ed9495aeb 100644 --- a/apps/dokploy/server/wss/terminal.ts +++ b/apps/dokploy/server/wss/terminal.ts @@ -10,7 +10,11 @@ import { Client, type ConnectConfig } from "ssh2"; import { WebSocketServer } from "ws"; import { getDockerHost } from "../utils/docker"; import { canAccessTerminalOverWss } from "./authorize"; -import { setupLocalServerSSHKey } from "./utils"; +import { + parseResizeMessage, + parseTerminalSize, + setupLocalServerSSHKey, +} from "./utils"; const COMMAND_TO_ALLOW_LOCAL_ACCESS = ` # ---------------------------------------- @@ -88,6 +92,10 @@ export const setupTerminalWebSocketServer = ( wssTerm.on("connection", async (ws, req) => { const url = new URL(req.url || "", `http://${req.headers.host}`); const serverId = url.searchParams.get("serverId"); + const { cols, rows } = parseTerminalSize( + url.searchParams.get("cols"), + url.searchParams.get("rows"), + ); const { user, session } = await validateRequest(req); if (!user || !session || !serverId) { ws.close(); @@ -190,7 +198,7 @@ export const setupTerminalWebSocketServer = ( // Clear terminal content once connected ws.send("\x1bc"); - conn.shell({}, (err, stream) => { + conn.shell({ cols, rows }, (err, stream) => { if (err) throw err; stream @@ -216,7 +224,13 @@ export const setupTerminalWebSocketServer = ( } else { command = message; } - stream.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + stream.setWindow(resize.rows, resize.cols, 0, 0); + return; + } + stream.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; diff --git a/apps/dokploy/server/wss/utils.ts b/apps/dokploy/server/wss/utils.ts index 346093e1b..fb47f08fe 100644 --- a/apps/dokploy/server/wss/utils.ts +++ b/apps/dokploy/server/wss/utils.ts @@ -63,6 +63,48 @@ export const isValidShell = (shell: string): boolean => { return allowedShells.includes(shell); }; +/** + * Clamps cols/rows read from the client's initial connection query params + * to a sane range, falling back to the standard 80x24 default. + */ +export const parseTerminalSize = ( + colsParam: string | null, + rowsParam: string | null, +) => { + const cols = Number(colsParam); + const rows = Number(rowsParam); + return { + cols: Number.isInteger(cols) && cols > 0 && cols <= 1000 ? cols : 80, + rows: Number.isInteger(rows) && rows > 0 && rows <= 1000 ? rows : 24, + }; +}; + +/** + * Terminal input and resize control messages share the same websocket + * channel. Resize messages are JSON envelopes; regular keystrokes never + * start with "{", so this distinguishes them without an extra channel. + */ +export const parseResizeMessage = (data: string) => { + if (!data.startsWith("{")) return null; + try { + const parsed = JSON.parse(data); + if ( + parsed?.type === "resize" && + Number.isInteger(parsed.cols) && + Number.isInteger(parsed.rows) && + parsed.cols > 0 && + parsed.cols <= 1000 && + parsed.rows > 0 && + parsed.rows <= 1000 + ) { + return { cols: parsed.cols, rows: parsed.rows }; + } + } catch { + return null; + } + return null; +}; + export const getShell = () => { if (IS_CLOUD) { return "NO_AVAILABLE"; diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts index b972d7d85..8d0475977 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -177,6 +177,17 @@ else OS_VERSION=$(grep -w "VERSION_ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') fi +# Ubuntu 26.04 (resolute) ships a docker-ce repo that has no 28.5.0, so the +# default pin is absent from apt-cache madison and get.docker.com aborts before +# installing Docker. Repin to a version present on every architecture Docker +# ships resolute for: amd64/arm64/armhf start at 29.3.1, but s390x only carries +# 29.4.0-29.4.2, so 29.4.2 is the newest version common to all of them. This +# must run after OS_VERSION is resolved and before the banner so the reported +# Docker version stays accurate. +if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "26.04" ]; then + DOCKER_VERSION=29.4.2 +fi + if [ "$OS_TYPE" = 'amzn' ]; then $SUDO_CMD dnf install -y findutils >/dev/null fi diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 168de7fda..31e610847 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -7,6 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain"; import { encodeBase64, getEnvironmentVariablesObject, + prepareEnvironmentVariables, prepareEnvironmentVariablesForFile, } from "../docker/utils"; import { withResolvedVaultRefs } from "../vault"; @@ -157,10 +158,18 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => { envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`; } - const envFileContent = prepareEnvironmentVariablesForFile( - envContent, - compose.environment.project.env, - compose.environment.env, + const envFileContent = ( + compose.composeType === "stack" + ? prepareEnvironmentVariables( + envContent, + compose.environment.project.env, + compose.environment.env, + ) + : prepareEnvironmentVariablesForFile( + envContent, + compose.environment.project.env, + compose.environment.env, + ) ).join("\n"); const encodedContent = encodeBase64(envFileContent); diff --git a/packages/server/src/utils/volume-backups/backup.ts b/packages/server/src/utils/volume-backups/backup.ts index a9240f2dd..3172c9c21 100644 --- a/packages/server/src/utils/volume-backups/backup.ts +++ b/packages/server/src/utils/volume-backups/backup.ts @@ -9,6 +9,43 @@ import { normalizeS3Path, } from "../backups/utils"; +interface RestartSafeBackupCommandOptions { + stopCommand: string; + backupCommand: string; + startCommand: string; + uploadCommand: string; +} + +export const createRestartSafeBackupCommand = ({ + stopCommand, + backupCommand, + startCommand, + uploadCommand, +}: RestartSafeBackupCommandOptions) => ` + ${stopCommand} + set +e + ( + ${backupCommand} + ) + DOKPLOY_VOLUME_BACKUP_STATUS=$? + ( + set -e + ${startCommand} + ) + DOKPLOY_VOLUME_RESTART_STATUS=$? + set -e + if [ "$DOKPLOY_VOLUME_BACKUP_STATUS" -ne 0 ]; then + if [ "$DOKPLOY_VOLUME_RESTART_STATUS" -ne 0 ]; then + echo "Service restart also failed with exit code $DOKPLOY_VOLUME_RESTART_STATUS" + fi + exit "$DOKPLOY_VOLUME_BACKUP_STATUS" + fi + if [ "$DOKPLOY_VOLUME_RESTART_STATUS" -ne 0 ]; then + exit "$DOKPLOY_VOLUME_RESTART_STATUS" + fi + ${uploadCommand} +`; + export const getVolumeServiceAppName = ( volumeBackup: Awaited>, ): string => { @@ -117,16 +154,20 @@ export const backupVolume = async ( ); if (serviceType === "application") { - return lockWrapper(` - echo "Stopping application to 0 replicas" - ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}") - echo "Actual replicas: $ACTUAL_REPLICAS" - docker service update --replicas=0 ${volumeBackup.application?.appName} - ${backupCommand} - echo "Starting application to $ACTUAL_REPLICAS replicas" - docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName} - ${uploadCommand} - `); + return lockWrapper( + createRestartSafeBackupCommand({ + stopCommand: ` + echo "Stopping application to 0 replicas" + ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}") + echo "Actual replicas: $ACTUAL_REPLICAS" + docker service update --replicas=0 ${volumeBackup.application?.appName}`, + backupCommand, + startCommand: ` + echo "Starting application to $ACTUAL_REPLICAS replicas" + docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}`, + uploadCommand, + }), + ); } if (serviceType === "compose") { const compose = await findComposeById( @@ -158,11 +199,13 @@ export const backupVolume = async ( echo "Compose container started" `; } - return lockWrapper(` - ${stopCommand} - ${backupCommand} - ${startCommand} - ${uploadCommand} - `); + return lockWrapper( + createRestartSafeBackupCommand({ + stopCommand, + backupCommand, + startCommand, + uploadCommand, + }), + ); } };