mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 23:21:36 +00:00
Merge branch 'Dokploy:canary' into canary
This commit is contained in:
@@ -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
|
||||
+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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -177,6 +177,17 @@ else
|
||||
OS_VERSION=$(grep -w "VERSION_ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
|
||||
fi
|
||||
|
||||
# Ubuntu 26.04 (resolute) ships a docker-ce repo that has no 28.5.0, so the
|
||||
# default pin is absent from apt-cache madison and get.docker.com aborts before
|
||||
# installing Docker. Repin to a version present on every architecture Docker
|
||||
# ships resolute for: amd64/arm64/armhf start at 29.3.1, but s390x only carries
|
||||
# 29.4.0-29.4.2, so 29.4.2 is the newest version common to all of them. This
|
||||
# must run after OS_VERSION is resolved and before the banner so the reported
|
||||
# Docker version stays accurate.
|
||||
if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "26.04" ]; then
|
||||
DOCKER_VERSION=29.4.2
|
||||
fi
|
||||
|
||||
if [ "$OS_TYPE" = 'amzn' ]; then
|
||||
$SUDO_CMD dnf install -y findutils >/dev/null
|
||||
fi
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user