mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-17 22:25:32 +00:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebeb95cefd | |||
| 2d8dfee9f3 | |||
| 3c6a96a30a | |||
| 546686ea35 | |||
| 217ca07842 | |||
| bd8ba128cd | |||
| 72a554de5f | |||
| cda047feb2 | |||
| feccb51287 | |||
| 1ccd633153 | |||
| 8be1e68fe1 | |||
| 875baa139c | |||
| e2ab2eb6bc | |||
| d675db10bb | |||
| 903789bfe5 | |||
| d1273e2fe9 | |||
| ede4626396 | |||
| 772b768217 | |||
| d3d8d9ec83 | |||
| f4dfb6a903 | |||
| 62f701425a | |||
| bee6918d9a | |||
| d7c152af0d | |||
| 5ff0c26954 | |||
| b4edb56132 | |||
| 9dab15ad47 | |||
| 0be822ef5b | |||
| a8e0477b70 | |||
| 5fa963fe87 | |||
| 3054cf53be | |||
| 8c2d4d5c19 | |||
| 64c3a93590 | |||
| fa3cab8bcc | |||
| 31ddd7fe1a | |||
| edc8fc1d7f | |||
| 8f42dced38 | |||
| fc6f4d9af8 | |||
| d183018201 | |||
| 7a0efc7aa4 | |||
| effa3ea502 | |||
| b10cad4f78 | |||
| 65c5ccc7b2 | |||
| cf6f5e39d2 | |||
| 33367fb86b | |||
| 5d1731c54f | |||
| e15b38be38 | |||
| 76c2416613 | |||
| 60612b0986 | |||
| 0ea7de7d31 | |||
| 49055a05b1 | |||
| 2221b8a99c | |||
| c77f6d889d | |||
| a5b8626345 | |||
| fa788b00a6 | |||
| 41d355c7dc | |||
| ded23bb05f | |||
| 717792f186 | |||
| c0265b49b4 | |||
| b53fface69 | |||
| ff275fe94e | |||
| ca578ed075 | |||
| a691b469a1 | |||
| 6d8aa9d8da | |||
| 3b8611771c | |||
| 2cb499fb64 | |||
| e541308a4f | |||
| b976c7b4f7 | |||
| 7f3a85ac38 | |||
| ac9ad72875 | |||
| d8b7c1ea5e | |||
| d542967ae4 | |||
| c6ab76fe09 | |||
| 2598a62771 | |||
| dec05aca22 | |||
| 98dbc59a02 | |||
| a504449f1d | |||
| eaec68023e | |||
| 2643ae0a86 | |||
| f59f11474b | |||
| f29a1a1976 | |||
| ade7a79bf1 | |||
| 55704ff98f | |||
| b9a8064371 |
@@ -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\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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" <<EOF
|
||||
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
||||
|
||||
| Service | Image |
|
||||
|---------|-------|
|
||||
| ci-pg-db | postgres:15 |
|
||||
| ci-mongo-db | mongo:7.0 |
|
||||
| ci-static-app | nginx:alpine (static site) |
|
||||
| ci-node-app | ealen/echo-server (Node.js hello-world) |
|
||||
| ci-go-app | traefik/whoami (Go HTTP server) |
|
||||
EOF
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"dokploy": {
|
||||
"type": "http",
|
||||
"url": "${DOKPLOY_BASE_URL}/api/mcp",
|
||||
"headers": { "Authorization": "Bearer ${DOKPLOY_TOKEN}" }
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
.env.local
|
||||
+4
-1
@@ -31,7 +31,7 @@ WORKDIR /app
|
||||
# Set production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y tini curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only the necessary files
|
||||
COPY --from=build /prod/dokploy/.next ./.next
|
||||
@@ -69,5 +69,8 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
# tini reaps HEALTHCHECK child processes that Node (as PID 1) leaves defunct.
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
|
||||
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
|
||||
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRestartSafeBackupCommand } from "@dokploy/server/utils/volume-backups/backup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const runCommand = (command: string) =>
|
||||
spawnSync("bash", ["-c", command], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const outputLines = (stdout: string) =>
|
||||
stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
describe("createRestartSafeBackupCommand", () => {
|
||||
it("restarts the service and preserves the backup error status", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"; exit 23',
|
||||
startCommand: 'echo "start"',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(23);
|
||||
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
|
||||
});
|
||||
|
||||
it("preserves the backup error status when the restart also fails", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"; exit 23',
|
||||
startCommand: 'echo "start"; exit 17',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(23);
|
||||
expect(outputLines(result.stdout)).toEqual([
|
||||
"stop",
|
||||
"backup",
|
||||
"start",
|
||||
"Service restart also failed with exit code 17",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the restart error status when the backup succeeds", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"',
|
||||
startCommand: 'echo "start"; exit 17',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(17);
|
||||
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
|
||||
});
|
||||
|
||||
it("uploads only after a successful backup and service restart", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"',
|
||||
startCommand: 'echo "start"',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(outputLines(result.stdout)).toEqual([
|
||||
"stop",
|
||||
"backup",
|
||||
"start",
|
||||
"upload",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -93,4 +93,46 @@ describe("getCreateEnvFileCommand", () => {
|
||||
expect(actual[key], key).toBe(value);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it("still interpolates a ${VAR} self-reference in the .env file", () => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
|
||||
const serviceEnv = [
|
||||
"APP_URL=https://example.com",
|
||||
'ASSET_URL="${APP_URL}"',
|
||||
].join("\n");
|
||||
|
||||
const command = getCreateEnvFileCommand({
|
||||
appName,
|
||||
composePath: "docker-compose.yml",
|
||||
env: serviceEnv,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as Parameters<typeof getCreateEnvFileCommand>[0]);
|
||||
|
||||
execFileSync("bash", ["-c", command]);
|
||||
|
||||
const composeFile =
|
||||
"services:\n test:\n image: busybox\n environment:\n - ASSET_URL=${ASSET_URL}\n";
|
||||
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
|
||||
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"compose",
|
||||
"run",
|
||||
"--rm",
|
||||
"-T",
|
||||
"test",
|
||||
"sh",
|
||||
"-c",
|
||||
"printf '%s' \"$ASSET_URL\"",
|
||||
],
|
||||
{ cwd: codePath, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(out).toBe("https://example.com");
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+51
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForFile,
|
||||
prepareEnvironmentVariablesForShell,
|
||||
} from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -642,3 +643,53 @@ SPECIAL=café résumé naïve
|
||||
expect(resolved[2]).toContain("café");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareEnvironmentVariablesForFile (.env file escaping)", () => {
|
||||
it("keeps a well-formed ${VAR} self-reference interpolatable", () => {
|
||||
const serviceEnv = `
|
||||
APP_URL=https://example.com
|
||||
ASSET_URL=\${APP_URL}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([
|
||||
`APP_URL="https://example.com"`,
|
||||
`ASSET_URL="\${APP_URL}"`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a ${VAR:-default} self-reference with a modifier interpolatable", () => {
|
||||
const serviceEnv = `ASSET_URL=\${APP_URL:-https://default.com}`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`ASSET_URL="\${APP_URL:-https://default.com}"`]);
|
||||
});
|
||||
|
||||
it("escapes a literal dollar sign followed by a word so it isn't interpolated", () => {
|
||||
const serviceEnv = "PASSWORD=pa$word";
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`PASSWORD="pa\\$word"`]);
|
||||
});
|
||||
|
||||
it("escapes a trailing dollar sign", () => {
|
||||
const serviceEnv = "PRICE=100$";
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`PRICE="100\\$"`]);
|
||||
});
|
||||
|
||||
it("still escapes double quotes and backslashes", () => {
|
||||
const serviceEnv = String.raw`MESSAGE=say "hi" and \backslash`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([
|
||||
String.raw`MESSAGE="say \"hi\" and \\backslash"`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||
|
||||
const containers = [
|
||||
{ containerId: "first-container" },
|
||||
{ containerId: "selected-container" },
|
||||
];
|
||||
|
||||
describe("resolveContainerSelection", () => {
|
||||
it("selects the first container when no container is selected", () => {
|
||||
expect(resolveContainerSelection(undefined, containers)).toBe(
|
||||
"first-container",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a manual selection when refreshed data contains it", () => {
|
||||
const refreshedContainers = containers.map((container) => ({
|
||||
...container,
|
||||
}));
|
||||
|
||||
expect(
|
||||
resolveContainerSelection("selected-container", refreshedContainers),
|
||||
).toBe("selected-container");
|
||||
});
|
||||
|
||||
it("falls back to the first container when the selection disappears", () => {
|
||||
expect(resolveContainerSelection("removed-container", containers)).toBe(
|
||||
"first-container",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the current selection while container data is loading", () => {
|
||||
expect(resolveContainerSelection("selected-container", undefined)).toBe(
|
||||
"selected-container",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the selection when no containers are available", () => {
|
||||
expect(resolveContainerSelection("selected-container", [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { docker } from "@dokploy/server/constants";
|
||||
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const REAL_TEST_TIMEOUT = 120000;
|
||||
const SERVICE_NAME = "dokploy-monitoring";
|
||||
const TEST_IMAGE = "busybox:latest";
|
||||
|
||||
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
|
||||
// the local daemon, so the legacy-container cleanup is exercised for real.
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn().mockResolvedValue({
|
||||
serverId: "test-server",
|
||||
serverType: "deploy",
|
||||
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
|
||||
metricsConfig: {
|
||||
server: {
|
||||
type: "Remote",
|
||||
port: 4500,
|
||||
token: "test-token",
|
||||
urlCallback: "http://localhost/callback",
|
||||
cronJob: "0 0 * * *",
|
||||
retentionDays: 2,
|
||||
refreshRate: 60,
|
||||
thresholds: { cpu: 0, memory: 0 },
|
||||
},
|
||||
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/settings", () => ({
|
||||
getDokployImageTag: vi.fn(() => "latest"),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/docker/utils", () => ({
|
||||
pullImage: vi.fn().mockResolvedValue(undefined),
|
||||
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
||||
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
||||
}));
|
||||
|
||||
const containerExists = async (name: string) => {
|
||||
try {
|
||||
await docker.getContainer(name).inspect();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const serviceExists = async (name: string) => {
|
||||
try {
|
||||
await docker.getService(name).inspect();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const swarmTaskNames = async () => {
|
||||
const list = await docker.listContainers({ all: true });
|
||||
return list
|
||||
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
|
||||
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
|
||||
};
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await docker.getService(SERVICE_NAME).remove();
|
||||
} catch {}
|
||||
try {
|
||||
await docker.getContainer(SERVICE_NAME).remove({ force: true });
|
||||
} catch {}
|
||||
for (const name of await swarmTaskNames()) {
|
||||
try {
|
||||
await docker.getContainer(name).remove({ force: true });
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
|
||||
const createLegacyZombie = async () => {
|
||||
const container = await docker.createContainer({
|
||||
name: SERVICE_NAME,
|
||||
Image: TEST_IMAGE,
|
||||
Cmd: [
|
||||
"sh",
|
||||
"-c",
|
||||
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
|
||||
],
|
||||
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
|
||||
});
|
||||
await container.start();
|
||||
};
|
||||
|
||||
const pullTestImage = async () => {
|
||||
try {
|
||||
await docker.getImage(TEST_IMAGE).inspect();
|
||||
return;
|
||||
} catch {}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
|
||||
err
|
||||
? reject(err)
|
||||
: docker.modem.followProgress(stream, (e: any) =>
|
||||
e ? reject(e) : resolve(),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// The code under test hardcodes the production name, so this suite cannot
|
||||
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
|
||||
const hasRealMonitoring = () => {
|
||||
const query = (cmd: string) => {
|
||||
try {
|
||||
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
query(
|
||||
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
|
||||
) !== "" ||
|
||||
query(
|
||||
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
|
||||
) !== ""
|
||||
);
|
||||
};
|
||||
|
||||
describe.skipIf(hasRealMonitoring())(
|
||||
"setupMonitoring - legacy container cleanup (real docker)",
|
||||
() => {
|
||||
beforeEach(async () => {
|
||||
await pullTestImage();
|
||||
await cleanup();
|
||||
}, REAL_TEST_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
}, REAL_TEST_TIMEOUT);
|
||||
|
||||
it(
|
||||
"removes the legacy standalone container left behind by the swarm migration",
|
||||
async () => {
|
||||
await createLegacyZombie();
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(true);
|
||||
|
||||
await setupMonitoring("test-server");
|
||||
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
|
||||
async () => {
|
||||
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
|
||||
const task = await docker.createContainer({
|
||||
name: taskName,
|
||||
Image: TEST_IMAGE,
|
||||
Cmd: ["sleep", "3600"],
|
||||
});
|
||||
await task.start();
|
||||
await createLegacyZombie();
|
||||
|
||||
await setupMonitoring("test-server");
|
||||
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
expect(await containerExists(taskName)).toBe(true);
|
||||
const inspect = await docker.getContainer(taskName).inspect();
|
||||
expect(inspect.State.Running).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"is idempotent when no legacy container exists",
|
||||
async () => {
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"deploys the service even when removing the legacy container fails",
|
||||
async () => {
|
||||
const failingDocker = {
|
||||
getContainer: () => ({
|
||||
remove: async () => {
|
||||
const error: any = new Error("device or resource busy");
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
getService: docker.getService.bind(docker),
|
||||
createService: docker.createService.bind(docker),
|
||||
};
|
||||
|
||||
const remoteDocker = await import(
|
||||
"@dokploy/server/utils/servers/remote-docker"
|
||||
);
|
||||
const spy = vi
|
||||
.spyOn(remoteDocker, "getRemoteDocker")
|
||||
.mockResolvedValue(failingDocker as any);
|
||||
|
||||
try {
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { reconnectServicesToTraefik } from "@dokploy/server/services/settings";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
compose: {
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: mocks.execAsync,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
describe("reconnectServicesToTraefik", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("does not execute an empty local command when no isolated deployments exist", async () => {
|
||||
await reconnectServicesToTraefik();
|
||||
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not execute an empty remote command when no isolated deployments exist", async () => {
|
||||
await reconnectServicesToTraefik("server-id");
|
||||
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconnects isolated deployments to the local Traefik network", async () => {
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{ appName: "first-compose" },
|
||||
{ appName: "second-compose" },
|
||||
]);
|
||||
|
||||
await reconnectServicesToTraefik();
|
||||
|
||||
expect(mocks.execAsync).toHaveBeenCalledOnce();
|
||||
expect(mocks.execAsync).toHaveBeenCalledWith(
|
||||
'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' +
|
||||
'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
|
||||
);
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconnects isolated deployments on a remote server", async () => {
|
||||
mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]);
|
||||
|
||||
await reconnectServicesToTraefik("server-id");
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
||||
"server-id",
|
||||
'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
|
||||
);
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const availableContainers = option === "native" ? containers : services;
|
||||
|
||||
useEffect(() => {
|
||||
if (option === "native") {
|
||||
if (containers && containers?.length > 0) {
|
||||
setContainerId(containers[0]?.containerId);
|
||||
}
|
||||
} else {
|
||||
if (services && services?.length > 0) {
|
||||
setContainerId(services[0]?.containerId);
|
||||
}
|
||||
}
|
||||
}, [option, services, containers]);
|
||||
setContainerId((currentContainerId) =>
|
||||
resolveContainerSelection(currentContainerId, availableContainers),
|
||||
);
|
||||
}, [availableContainers]);
|
||||
|
||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||
const containersLength =
|
||||
@@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
|
||||
<Switch
|
||||
checked={option === "native"}
|
||||
onCheckedChange={(checked) => {
|
||||
setContainerId(undefined);
|
||||
setOption(checked ? "native" : "swarm");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -70,18 +71,13 @@ export const ShowDockerLogsStack = ({
|
||||
);
|
||||
|
||||
const containers = data?.filter((container) => container.containerId);
|
||||
const availableContainers = option === "native" ? containers : services;
|
||||
|
||||
useEffect(() => {
|
||||
if (option === "native") {
|
||||
if (containers && containers?.length > 0) {
|
||||
setContainerId(containers[0]?.containerId);
|
||||
}
|
||||
} else {
|
||||
if (services && services?.length > 0) {
|
||||
setContainerId(services[0]?.containerId);
|
||||
}
|
||||
}
|
||||
}, [option, services, containers]);
|
||||
setContainerId((currentContainerId) =>
|
||||
resolveContainerSelection(currentContainerId, availableContainers),
|
||||
);
|
||||
}, [availableContainers]);
|
||||
|
||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||
const containersLength =
|
||||
@@ -106,6 +102,7 @@ export const ShowDockerLogsStack = ({
|
||||
<Switch
|
||||
checked={option === "native"}
|
||||
onCheckedChange={(checked) => {
|
||||
setContainerId(undefined);
|
||||
setOption(checked ? "native" : "swarm");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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<Props> = ({
|
||||
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<Props> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerId) return;
|
||||
if (!hasContainer) return;
|
||||
|
||||
let isCurrentConnection = true;
|
||||
let noDataTimeout: NodeJS.Timeout;
|
||||
@@ -425,10 +433,15 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
) : hasContainer ? (
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
No logs found
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-full text-center text-sm text-muted-foreground px-8">
|
||||
Select a container above to view its logs. If none are listed,
|
||||
make sure the service is deployed and running.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,28 @@ export interface LogLine {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ContainerOption {
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export const resolveContainerSelection = (
|
||||
currentContainerId: string | undefined,
|
||||
containers: readonly ContainerOption[] | undefined,
|
||||
) => {
|
||||
if (!containers) {
|
||||
return currentContainerId;
|
||||
}
|
||||
|
||||
if (
|
||||
currentContainerId &&
|
||||
containers.some(({ containerId }) => containerId === currentContainerId)
|
||||
) {
|
||||
return currentContainerId;
|
||||
}
|
||||
|
||||
return containers[0]?.containerId;
|
||||
};
|
||||
|
||||
interface LogStyle {
|
||||
type: LogType;
|
||||
variant: LogVariant;
|
||||
|
||||
@@ -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<Props> = ({
|
||||
id,
|
||||
containerId,
|
||||
@@ -25,59 +29,105 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
const [activeWay, setActiveWay] = React.useState<string | undefined>("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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<span>
|
||||
Select way to connect to <b>{containerId}</b>
|
||||
</span>
|
||||
<Tabs value={activeWay} onValueChange={setActiveWay}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="bash">Bash</TabsTrigger>
|
||||
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="w-full h-full rounded-lg p-2 bg-transparent border">
|
||||
<div id={id} ref={termRef} />
|
||||
</div>
|
||||
{hasContainer && (
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<span>
|
||||
Select way to connect to <b>{containerId}</b>
|
||||
</span>
|
||||
<Tabs value={activeWay} onValueChange={setActiveWay}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="bash">Bash</TabsTrigger>
|
||||
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
{hasContainer ? (
|
||||
<div className="w-full h-[420px] rounded-lg p-2 bg-transparent border">
|
||||
<div id={id} ref={termRef} className="h-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[420px] w-full items-center justify-center rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
Select a container above to open a terminal. If none are listed, make
|
||||
sure the service is deployed and running.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53,9 +53,14 @@ type Tag = z.infer<typeof TagSchema>;
|
||||
|
||||
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<HTMLInputElement>(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) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<form
|
||||
id="hook-form-tag"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onSubmit={(e) => {
|
||||
e.stopPropagation();
|
||||
form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
|
||||
@@ -41,11 +41,22 @@ export const Terminal: React.FC<Props> = ({ 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<Props> = ({ 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]);
|
||||
|
||||
@@ -8,8 +8,10 @@ import { Button } from "../ui/button";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
/** Overrides the default quote in the left panel (desktop only). */
|
||||
leftPanel?: React.ReactNode;
|
||||
}
|
||||
export const OnboardingLayout = ({ children }: Props) => {
|
||||
export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const appDescription =
|
||||
@@ -30,9 +32,11 @@ export const OnboardingLayout = ({ children }: Props) => {
|
||||
{appName}
|
||||
</Link>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg text-primary">{appDescription}</p>
|
||||
</blockquote>
|
||||
{leftPanel ?? (
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg text-primary">{appDescription}</p>
|
||||
</blockquote>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-svh w-full flex-col">
|
||||
|
||||
@@ -13,6 +13,8 @@ export function SignInWithGithub() {
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "github",
|
||||
callbackURL: "/dashboard/home",
|
||||
newUserCallbackURL: "/dashboard/home?signup=github",
|
||||
errorCallbackURL: "/",
|
||||
});
|
||||
if (error) {
|
||||
|
||||
@@ -13,6 +13,8 @@ export function SignInWithGoogle() {
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/dashboard/home",
|
||||
newUserCallbackURL: "/dashboard/home?signup=google",
|
||||
errorCallbackURL: "/",
|
||||
});
|
||||
if (error) {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Boxes,
|
||||
Cloud,
|
||||
Database,
|
||||
Layers,
|
||||
LayoutTemplate,
|
||||
Rocket,
|
||||
Shield,
|
||||
Terminal,
|
||||
Unlock,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShowcaseItem {
|
||||
icon: typeof Users;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SLIDES: ShowcaseItem[][] = [
|
||||
[
|
||||
{
|
||||
icon: Users,
|
||||
title: "Team access",
|
||||
description:
|
||||
"Anyone in your team can deploy simply and securely in a safe, governed environment.",
|
||||
},
|
||||
{
|
||||
icon: Layers,
|
||||
title: "Open source",
|
||||
description:
|
||||
"Deploy for free with the open-source alternative to Netlify, Vercel, and Heroku.",
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
title: "AI sandbox",
|
||||
description:
|
||||
"Unleash the power of AI and test AI-generated code in a sandbox before deploying to a live URL.",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Enterprise ready",
|
||||
description:
|
||||
"Scale when you're ready with granular RBAC, SSO, audit logs, rollback and multi-tenancy.",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: Rocket,
|
||||
title: "Any stack",
|
||||
description:
|
||||
"Deploy any application using Nixpacks, Heroku Buildpacks, or your own Dockerfile.",
|
||||
},
|
||||
{
|
||||
icon: Boxes,
|
||||
title: "Docker Compose",
|
||||
description:
|
||||
"Deploy complex applications natively with full Docker Compose integration.",
|
||||
},
|
||||
{
|
||||
icon: Cloud,
|
||||
title: "Multi-server",
|
||||
description:
|
||||
"Effortlessly deploy your applications on remote servers, with zero configuration hassle.",
|
||||
},
|
||||
{
|
||||
icon: LayoutTemplate,
|
||||
title: "Ready templates",
|
||||
description:
|
||||
"Get started quickly with pre-configured templates for Supabase, Cal.com, PocketBase, and more.",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: Database,
|
||||
title: "Managed databases",
|
||||
description:
|
||||
"Manage and back up MySQL, PostgreSQL, MongoDB, MariaDB, and Redis directly from Dokploy.",
|
||||
},
|
||||
{
|
||||
icon: Terminal,
|
||||
title: "API & CLI",
|
||||
description: "Full API and CLI access to fit any custom workflow.",
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
title: "Live monitoring",
|
||||
description:
|
||||
"Monitor CPU, memory, and network usage in real time across every deployment.",
|
||||
},
|
||||
{
|
||||
icon: Unlock,
|
||||
title: "No lock-in",
|
||||
description:
|
||||
"Modify, scale, and customize Dokploy however your project needs.",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const AUTO_ADVANCE_MS = 6000;
|
||||
|
||||
export const SignupShowcase = () => {
|
||||
const [active, setActive] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setReduceMotion(query.matches);
|
||||
const listener = (e: MediaQueryListEvent) => setReduceMotion(e.matches);
|
||||
query.addEventListener("change", listener);
|
||||
return () => query.removeEventListener("change", listener);
|
||||
}, []);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || reduceMotion) {
|
||||
return;
|
||||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
setActive((prev) => (prev + 1) % SLIDES.length);
|
||||
}, AUTO_ADVANCE_MS);
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [paused, reduceMotion]);
|
||||
|
||||
const goTo = (index: number) => {
|
||||
setActive(((index % SLIDES.length) + SLIDES.length) % SLIDES.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocus={() => setPaused(true)}
|
||||
onBlur={() => setPaused(false)}
|
||||
>
|
||||
<div
|
||||
key={active}
|
||||
className={cn(
|
||||
"grid grid-cols-2 gap-4",
|
||||
!reduceMotion &&
|
||||
"animate-in fade-in slide-in-from-bottom-2 duration-500 ease-out",
|
||||
)}
|
||||
>
|
||||
{SLIDES[active]?.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="group flex flex-col gap-3 rounded-lg border border-primary/10 bg-background/40 p-4 transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:bg-background/60"
|
||||
>
|
||||
<div className="flex size-9 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors duration-300 group-hover:bg-primary/15">
|
||||
<item.icon className="size-4.5" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-primary">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground leading-snug">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
{SLIDES.map((slide, index) => (
|
||||
<button
|
||||
key={slide.map((item) => item.title).join("-")}
|
||||
type="button"
|
||||
aria-label={`Show showcase slide ${index + 1}`}
|
||||
aria-current={index === active}
|
||||
onClick={() => goTo(index)}
|
||||
className={cn(
|
||||
"h-1.5 rounded-full transition-all duration-300",
|
||||
index === active
|
||||
? "w-6 bg-primary"
|
||||
: "w-1.5 bg-primary/20 hover:bg-primary/40",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next showcase slide"
|
||||
onClick={() => goTo(active + 1)}
|
||||
className="absolute top-1/2 right-0 z-30 flex size-9 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full border border-primary/15 bg-background text-primary shadow-sm transition-all duration-300 hover:scale-105 hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<ArrowRight className="size-4" strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -147,7 +147,13 @@ export function TagSelector({
|
||||
})}
|
||||
</CommandGroup>
|
||||
<div className="flex items-center justify-center p-2 border-t">
|
||||
<HandleTag />
|
||||
<HandleTag
|
||||
onCreated={(tagId) => {
|
||||
if (!selectedTags.includes(tagId)) {
|
||||
onTagsChange([...selectedTags, tagId]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -22,7 +22,7 @@ function Switch({
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[18px] group-data-[size=sm]/switch:data-checked:translate-x-[10px] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.30.0",
|
||||
"version": "v0.30.2",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { pushToDataLayer } from "@/lib/analytics";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
@@ -139,6 +140,9 @@ const Invitation = ({
|
||||
});
|
||||
|
||||
toast.success("Account created successfully");
|
||||
if (isCloud) {
|
||||
pushToDataLayer("sign_up", { method: "invitation" });
|
||||
}
|
||||
router.push("/dashboard/home");
|
||||
} catch {
|
||||
toast.error("An error occurred while creating your account");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { z } from "zod";
|
||||
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
||||
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
|
||||
import { SignInWithGoogle } from "@/components/proprietary/auth/sign-in-with-google";
|
||||
import { SignupShowcase } from "@/components/proprietary/auth/signup-showcase";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { pushToDataLayer } from "@/lib/analytics";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
@@ -116,6 +118,7 @@ const Register = ({ isCloud }: Props) => {
|
||||
if (!isCloud) {
|
||||
router.push("/");
|
||||
} else {
|
||||
pushToDataLayer("sign_up", { method: "email" });
|
||||
setData(data);
|
||||
}
|
||||
}
|
||||
@@ -294,7 +297,12 @@ const Register = ({ isCloud }: Props) => {
|
||||
export default Register;
|
||||
|
||||
Register.getLayout = (page: ReactElement) => {
|
||||
return <OnboardingLayout>{page}</OnboardingLayout>;
|
||||
const isCloud = (page.props as Props).isCloud;
|
||||
return (
|
||||
<OnboardingLayout leftPanel={isCloud ? <SignupShowcase /> : undefined}>
|
||||
{page}
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (IS_CLOUD) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
checkProjectAccess,
|
||||
findMemberByUserId,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { serviceColumns } from "@dokploy/server/services/project";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import type { AnyPgColumn } from "drizzle-orm/pg-core";
|
||||
@@ -130,39 +131,63 @@ export const projectRouter = createTRPCRouter({
|
||||
environments: {
|
||||
with: {
|
||||
applications: {
|
||||
columns: {
|
||||
...serviceColumns,
|
||||
applicationId: true,
|
||||
icon: true,
|
||||
},
|
||||
with: { server: { columns: { name: true } } },
|
||||
where: buildServiceFilter(
|
||||
applications.applicationId,
|
||||
accessedServices,
|
||||
),
|
||||
},
|
||||
compose: {
|
||||
columns: {
|
||||
...serviceColumns,
|
||||
composeId: true,
|
||||
composeStatus: true,
|
||||
},
|
||||
with: { server: { columns: { name: true } } },
|
||||
where: buildServiceFilter(
|
||||
compose.composeId,
|
||||
accessedServices,
|
||||
),
|
||||
},
|
||||
libsql: {
|
||||
columns: { ...serviceColumns, libsqlId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
where: buildServiceFilter(libsql.libsqlId, accessedServices),
|
||||
},
|
||||
mariadb: {
|
||||
columns: { ...serviceColumns, mariadbId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
where: buildServiceFilter(
|
||||
mariadb.mariadbId,
|
||||
accessedServices,
|
||||
),
|
||||
},
|
||||
mongo: {
|
||||
columns: { ...serviceColumns, mongoId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
where: buildServiceFilter(mongo.mongoId, accessedServices),
|
||||
},
|
||||
mysql: {
|
||||
columns: { ...serviceColumns, mysqlId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
where: buildServiceFilter(mysql.mysqlId, accessedServices),
|
||||
},
|
||||
postgres: {
|
||||
columns: { ...serviceColumns, postgresId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
where: buildServiceFilter(
|
||||
postgres.postgresId,
|
||||
accessedServices,
|
||||
),
|
||||
},
|
||||
redis: {
|
||||
columns: { ...serviceColumns, redisId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
where: buildServiceFilter(redis.redisId, accessedServices),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -47,16 +47,16 @@ export const createProject = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const findProjectById = async (projectId: string) => {
|
||||
const serviceColumns = {
|
||||
name: true,
|
||||
description: true,
|
||||
appName: true,
|
||||
createdAt: true,
|
||||
serverId: true,
|
||||
applicationStatus: true,
|
||||
} as const;
|
||||
export const serviceColumns = {
|
||||
name: true,
|
||||
description: true,
|
||||
appName: true,
|
||||
createdAt: true,
|
||||
serverId: true,
|
||||
applicationStatus: true,
|
||||
} as const;
|
||||
|
||||
export const findProjectById = async (projectId: string) => {
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(projects.projectId, projectId),
|
||||
with: {
|
||||
|
||||
@@ -485,7 +485,7 @@ export const reconnectServicesToTraefik = async (serverId?: string) => {
|
||||
),
|
||||
});
|
||||
|
||||
if (!composeResult) {
|
||||
if (composeResult.length === 0) {
|
||||
return;
|
||||
}
|
||||
let commands = "";
|
||||
|
||||
@@ -21,11 +21,31 @@ const getMonitoringImage = () => {
|
||||
return imageName;
|
||||
};
|
||||
|
||||
// Swarm tasks are dokploy-monitoring.<slot>.<id>, so this only matches the
|
||||
// pre-v0.30.0 standalone container. A cleanup failure must not block the deploy.
|
||||
const removeLegacyContainer = async (
|
||||
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
|
||||
serviceName: string,
|
||||
) => {
|
||||
try {
|
||||
await docker.getContainer(serviceName).remove({ force: true });
|
||||
console.log("Removed legacy monitoring container ✅");
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode !== 404) {
|
||||
console.warn(
|
||||
`Could not remove legacy monitoring container: ${error?.message ?? error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deployMonitoringService = async (
|
||||
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
|
||||
serviceName: string,
|
||||
settings: CreateServiceOptions,
|
||||
) => {
|
||||
await removeLegacyContainer(docker, serviceName);
|
||||
|
||||
try {
|
||||
const service = docker.getService(serviceName);
|
||||
const inspect = await service.inspect();
|
||||
|
||||
@@ -86,6 +86,7 @@ export const serverSetup = async (
|
||||
...server.metricsConfig.server,
|
||||
token: token,
|
||||
urlCallback: urlCallback,
|
||||
cronJob: server.metricsConfig.server.cronJob || "0 0 * * *",
|
||||
},
|
||||
containers: server.metricsConfig.containers,
|
||||
},
|
||||
@@ -177,6 +178,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -548,7 +548,7 @@ export const prepareEnvironmentVariablesForFile = (
|
||||
const escapedValue = value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, "\\$");
|
||||
.replace(/\$(?!\{[A-Za-z_][A-Za-z0-9_]*(?::?[-+?][^}]*)?\})/g, "\\$");
|
||||
return `${key}="${escapedValue}"`;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<ReturnType<typeof findVolumeBackupById>>,
|
||||
): 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ export const readValidDirectory = (
|
||||
directory: string,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
if (!/^[\w/. :[\]-]{1,500}$/.test(directory)) {
|
||||
if (!/^[\w/. :[\]+@~(),=%-]{1,500}$/.test(directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PAYLOAD=$(cat)
|
||||
WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty')
|
||||
|
||||
if [ -z "$WORKTREE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ENV_FILE="$WORKTREE_PATH/apps/dokploy/.env"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FREE_PORT=$(node "$CLAUDE_PROJECT_DIR/scripts/find-free-port.mjs")
|
||||
|
||||
sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" "$ENV_FILE"
|
||||
sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" "$ENV_FILE"
|
||||
rm -f "$ENV_FILE.bak"
|
||||
|
||||
echo "Worktree $WORKTREE_PATH -> dokploy dev PORT=$FREE_PORT"
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createServer } from "node:net";
|
||||
|
||||
function isFree(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findFreePort(start) {
|
||||
let port = start;
|
||||
while (!(await isFree(port))) {
|
||||
port++;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
const start = Number.parseInt(
|
||||
process.argv[2] || process.env.PORT || "3000",
|
||||
10,
|
||||
);
|
||||
const port = await findFreePort(start);
|
||||
process.stdout.write(String(port));
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PAYLOAD=$(cat)
|
||||
WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty')
|
||||
|
||||
if [ -z "$WORKTREE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Turbopack refuses to resolve through anything outside its detected
|
||||
# workspace root, so a symlinked node_modules (whole dir or per-entry)
|
||||
# doesn't work for apps/dokploy. A real `pnpm install` is required, but
|
||||
# since pnpm's global content-addressable store is already warm, this
|
||||
# only links locally — no network fetch, a few seconds.
|
||||
cd "$WORKTREE_PATH"
|
||||
pnpm install --prefer-offline
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NAME="${1:?usage: spawn-agent-worktree.sh <name>}"
|
||||
# --show-toplevel would return the CURRENT worktree's own root if this is
|
||||
# run from inside one (e.g. another agent's worktree) instead of the main
|
||||
# checkout. --git-common-dir always points at the shared .git regardless of
|
||||
# which worktree you're standing in.
|
||||
REPO_ROOT="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")"
|
||||
WORKTREE_PATH="$REPO_ROOT/.claude/worktrees/$NAME"
|
||||
BRANCH="worktree-$NAME"
|
||||
|
||||
if [ -e "$WORKTREE_PATH" ]; then
|
||||
echo "Worktree already exists: $WORKTREE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$REPO_ROOT" fetch origin canary --quiet
|
||||
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WORKTREE_PATH" origin/canary >&2
|
||||
|
||||
# .worktreeinclude lists gitignored files (.env, .env.local) that a plain
|
||||
# `git worktree add` won't check out on its own - copy them in.
|
||||
while IFS= read -r pattern; do
|
||||
[ -z "$pattern" ] && continue
|
||||
find "$REPO_ROOT" \( -path "$REPO_ROOT/.claude/worktrees" -o -path "$REPO_ROOT/node_modules" \) -prune -o -name "$pattern" -print 2>/dev/null
|
||||
done < "$REPO_ROOT/.worktreeinclude" | while read -r src; do
|
||||
rel="${src#"$REPO_ROOT"/}"
|
||||
dest="$WORKTREE_PATH/$rel"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp "$src" "$dest"
|
||||
done
|
||||
|
||||
cd "$WORKTREE_PATH"
|
||||
pnpm install --prefer-offline >&2
|
||||
|
||||
FREE_PORT=$(node "$REPO_ROOT/scripts/find-free-port.mjs")
|
||||
sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" apps/dokploy/.env
|
||||
sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" apps/dokploy/.env
|
||||
rm -f apps/dokploy/.env.bak
|
||||
|
||||
pnpm --filter=dokploy run dev > "$WORKTREE_PATH/dev-server.log" 2>&1 &
|
||||
echo $! > "$WORKTREE_PATH/dev-server.pid"
|
||||
|
||||
BASE_URL="http://localhost:$FREE_PORT"
|
||||
for _ in $(seq 1 30); do
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$BASE_URL/" || true)
|
||||
if [ "$CODE" != "000" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "export WORKTREE_PATH=$WORKTREE_PATH"
|
||||
echo "export DOKPLOY_BASE_URL=$BASE_URL"
|
||||
echo "export PORT=$FREE_PORT"
|
||||
Reference in New Issue
Block a user