Merge branch 'Dokploy:canary' into canary

This commit is contained in:
Shuvo
2026-08-19 11:53:07 +06:00
committed by GitHub
23 changed files with 1073 additions and 147 deletions
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.30.0",
"version": "v0.30.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
+9 -4
View File
@@ -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,
+10 -10
View File
@@ -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 =
+10 -10
View File
@@ -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
+10 -10
View File
@@ -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 =
+10 -10
View File
@@ -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 -10
View File
@@ -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;
+17 -3
View File
@@ -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;
+42
View File
@@ -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";
+11
View File
@@ -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
+13 -4
View File
@@ -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,
}),
);
}
};