Compare commits

..

9 Commits

860 changed files with 58673 additions and 550525 deletions

View File

@@ -1,21 +0,0 @@
# Dockerfile for DevContainer
FROM node:24.4.0-bullseye-slim
# Install essential packages
RUN apt-get update && apt-get install -y \
curl \
bash \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up PNPM
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.22.0 --activate
# Create workspace directory
WORKDIR /workspaces/dokploy
# Set up user permissions
USER node

View File

@@ -1,53 +0,0 @@
{
"name": "Dokploy development container",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/git:1": {
"ppa": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.20"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-json",
"biomejs.biome",
"golang.go",
"redhat.vscode-xml",
"github.vscode-github-actions",
"github.copilot",
"github.copilot-chat"
]
}
},
"forwardPorts": [3000, 5432, 6379],
"portsAttributes": {
"3000": {
"label": "Dokploy App",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "silent"
},
"6379": {
"label": "Redis",
"onAutoForward": "silent"
}
},
"remoteUser": "node",
"workspaceFolder": "/workspaces/dokploy",
"runArgs": ["--name", "dokploy-devcontainer"]
}

View File

@@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about.
Before submitting this PR, please make sure that:
- [ ] You created a dedicated branch based on the `canary` branch.
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.
- [] You created a dedicated branch based on the `canary` branch.
- [] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
- [] You have tested this PR in your local instance.
## Issues related (if applicable)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -13,17 +13,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set tag and version
id: meta-cloud
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -36,7 +25,8 @@ jobs:
context: .
file: ./Dockerfile.cloud
push: true
tags: ${{ steps.meta-cloud.outputs.tags }}
tags: |
siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64
build-args: |
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
@@ -50,16 +40,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set tag and version
id: meta-schedule
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -72,7 +52,8 @@ jobs:
context: .
file: ./Dockerfile.schedule
push: true
tags: ${{ steps.meta-schedule.outputs.tags }}
tags: |
siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64
build-and-push-server-image:
@@ -82,16 +63,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set tag and version
id: meta-server
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -104,5 +75,6 @@ jobs:
context: .
file: ./Dockerfile.server
push: true
tags: ${{ steps.meta-server.outputs.tags }}
tags: |
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64

View File

@@ -138,8 +138,6 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -162,80 +160,3 @@ jobs:
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sync-version:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"

View File

@@ -1,21 +0,0 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
blocked-commit-authors: "claude,copilot"
require-description: true
min-account-age: 5

View File

@@ -18,34 +18,8 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
node-version: 20.16.0
cache: "pnpm"
- name: Install Nixpacks
if: matrix.job == 'test'
run: |
export NIXPACKS_VERSION=1.41.0
curl -sSL https://nixpacks.com/install.sh | bash
echo "Nixpacks installed $NIXPACKS_VERSION"
- name: Install Railpack
if: matrix.job == 'test'
run: |
export RAILPACK_VERSION=0.15.4
curl -sSL https://railpack.com/install.sh | bash
echo "Railpack installed $RAILPACK_VERSION"
- name: Add build tools to PATH
if: matrix.job == 'test'
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Initialize Docker Swarm
if: matrix.job == 'test'
run: |
docker swarm init
docker network create --driver overlay dokploy-network || true
echo "✅ Docker Swarm initialized"
- run: pnpm install --frozen-lockfile
- run: pnpm server:build
- run: pnpm ${{ matrix.job }}

View File

@@ -1,133 +0,0 @@
name: Generate and Sync OpenAPI
on:
push:
branches:
- canary
- main
paths:
- 'apps/dokploy/server/api/routers/**'
- 'packages/server/src/services/**'
- 'packages/server/src/db/schema/**'
workflow_dispatch:
jobs:
generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate OpenAPI specification
run: |
pnpm generate:openapi
# Verifica que se generó correctamente
if [ ! -f openapi.json ]; then
echo "❌ openapi.json not found"
exit 1
fi
echo "✅ OpenAPI specification generated successfully"
- name: Sync to website repository
run: |
# Clona el repositorio de website
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
cd website-repo
# Copia el openapi.json al website (sobrescribe)
mkdir -p apps/docs/public
cp -f ../openapi.json apps/docs/public/openapi.json
# Configura git
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
# Agrega y commitea siempre
git add apps/docs/public/openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to website successfully"
- name: Sync to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to MCP repository successfully"
- name: Sync to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
cd cli-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to CLI repository successfully"
- name: Sync to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
cd sdk-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to SDK repository successfully"

2
.gitignore vendored
View File

@@ -13,8 +13,6 @@ node_modules
.env.test.local
.env.production.local
openapi.json
# Testing
coverage

2
.nvmrc
View File

@@ -1 +1 @@
24.4.0
20.16.0

View File

@@ -4,8 +4,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}

View File

@@ -2,7 +2,7 @@
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues.
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
We have a few guidelines to follow when contributing to this project:
@@ -11,7 +11,6 @@ We have a few guidelines to follow when contributing to this project:
- [Development](#development)
- [Build](#build)
- [Pull Request](#pull-request)
- [Important Considerations](#important-considerations-for-pull-requests)
## Commit Convention
@@ -53,7 +52,7 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory.
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
```bash
git clone https://github.com/dokploy/dokploy.git
@@ -99,14 +98,7 @@ pnpm run dokploy:build
## Docker
To build the docker image first run commands to copy .env files
```bash
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
```
then run build command
To build the docker image
```bash
pnpm run docker:build
@@ -156,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
```bash
# Install Buildpacks
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
```
## Pull Request
@@ -170,13 +162,11 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
- If your pull request fixes an open issue, please reference the issue in the pull request description.
- Once your pull request is merged, you will be automatically added as a contributor to the project.
### Important Considerations for Pull Requests
**Important Considerations for Pull Requests:**
- **Testing is Mandatory:** All Pull Requests **must be tested** by the PR author before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested by their creator will be rejected.** This policy keeps the PR history clean and values contributors who submit verified, working code. Untested PRs are often recognizable by disproportionately large or scattered changes for simple tasks—please test first.
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
Thank you for your contribution!

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:24.4.0-slim AS base
FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.22.0 --activate
RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/dokploy run build
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
@@ -46,27 +46,23 @@ COPY --from=build /prod/dokploy/node_modules ./node_modules
# Install docker
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh && curl https://rclone.org/install.sh | bash
# Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash
ARG NIXPACKS_VERSION=1.41.0
ARG NIXPACKS_VERSION=1.39.0
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh \
&& pnpm install -g tsx
# Install Railpack
ARG RAILPACK_VERSION=0.15.4
ARG RAILPACK_VERSION=0.2.2
RUN curl -sSL https://railpack.com/install.sh | bash
# Install buildpacks
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
CMD [ "pnpm", "start" ]

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:24.4.0-slim AS base
FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.22.0 --activate
RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
# Deploy only the dokploy app
# ARG NEXT_PUBLIC_UMAMI_HOST
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
ARG NEXT_PUBLIC_UMAMI_HOST
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
@@ -29,7 +29,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/dokploy run build
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
RUN pnpm install -g tsx
EXPOSE 3000
CMD [ "pnpm", "start" ]
CMD [ "pnpm", "start" ]

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:24.4.0-slim AS base
FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.22.0 --activate
RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/schedules run build
RUN pnpm --filter=./apps/schedules --prod deploy --legacy /prod/schedules
RUN pnpm --filter=./apps/schedules --prod deploy /prod/schedules
RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist
@@ -35,5 +35,4 @@ COPY --from=build /prod/schedules/dist ./dist
COPY --from=build /prod/schedules/package.json ./package.json
COPY --from=build /prod/schedules/node_modules ./node_modules
ENV HOSTNAME=0.0.0.0
CMD ["pnpm", "start"]
CMD HOSTNAME=0.0.0.0 && pnpm start

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1
FROM node:24.4.0-slim AS base
FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.22.0 --activate
RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/api run build
RUN pnpm --filter=./apps/api --prod deploy --legacy /prod/api
RUN pnpm --filter=./apps/api --prod deploy /prod/api
RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist
@@ -35,5 +35,4 @@ COPY --from=build /prod/api/dist ./dist
COPY --from=build /prod/api/package.json ./package.json
COPY --from=build /prod/api/node_modules ./node_modules
ENV HOSTNAME=0.0.0.0
CMD ["pnpm", "start"]
CMD HOSTNAME=0.0.0.0 && pnpm start

View File

@@ -1,13 +1,8 @@
Copyright 2026-present Dokploy Technology, Inc.
# License
Portions of this software are licensed as follows:
## Core License (Apache License 2.0)
* All content that resides under a "/proprietary" directory of this repository, if that directory exists, is licensed under the license defined in "LICENSE_PROPRIETARY".
* Content outside of the above mentioned directories or restrictions above is available under the "Apache License 2.0" license as defined below.
## Apache License 2.0
Copyright 2026-present Dokploy Technology, Inc.
Copyright 2025 Mauricio Siu.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,4 +15,12 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
## Additional Terms for Specific Features
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.

View File

@@ -1,11 +0,0 @@
The Dokploy Source Available license (DSAL) version 1.0
Copyright (c) 2026-present Dokploy Technology, Inc.
With regard to the Dokploy Software:This software and associated documentation files (the "Software") may only beused in production, if you (and any entity that you represent) have agreed to, and are in compliance with, a valid commercial agreement from Dokploy.Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Dokploy Source Available License.  Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription.  You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications.  You are not granted any other rights beyond what is expressly stated herein.  Subject to theforegoing, it is forbidden to copy, merge, publish, distribute, sublicense,and/or sell the Software.
This Dokploy Source Available license applies only to the part of this Software that is in a /proprietary folder. The full text of this License shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.
For all third party components incorporated into the Dokploy Software, thosecomponents are licensed under the original license provided by the owner of the applicable component.

View File

@@ -12,14 +12,30 @@
<br />
<div align="center" markdown="1">
<sup>Special thanks to:</sup>
<br>
<br>
<a href="https://tuple.app/dokploy">
<img src=".github/sponsors/tuple.png" alt="Tuple's sponsorship image" width="400"/>
</a>
### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy)
[Available for MacOS & Windows](https://tuple.app/dokploy)<br>
</div>
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
## ✨ Features
Dokploy includes multiple features to make your life easier.
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis.
- **Backups**: Automate backups for databases to an external storage destination.
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
@@ -39,14 +55,71 @@ To get started, run the following command on a VPS:
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
```bash
curl -sSL https://dokploy.com/install.sh | bash
curl -sSL https://dokploy.com/install.sh | sh
```
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
## ♥️ Sponsors
🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features.
[Dokploy Open Collective](https://opencollective.com/dokploy)
[Github Sponsors](https://github.com/sponsors/Siumauricio)
<!-- Hero Sponsors 🎖 -->
<!-- Add Hero Sponsors here -->
### Hero Sponsors 🎖
<div>
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
</div>
<!-- Premium Supporters 🥇 -->
<!-- Add Premium Supporters here -->
### Premium Supporters 🥇
<div>
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
</div>
<!-- Elite Contributors 🥈 -->
<!-- Add Elite Contributors here -->
### Elite Contributors 🥈
<div>
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
</div>
### Supporting Members 🥉
<div>
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
</div>
### Community Backers 🤝
#### Organizations:
[Sponsors on Open Collective](https://opencollective.com/dokploy)
#### Individuals:
[![Individual Contributors on Open Collective](https://opencollective.com/dokploy/individuals.svg?width=890)](https://opencollective.com/dokploy)
### Contributors 🤝
<a href="https://github.com/dokploy/dokploy/graphs/contributors">

8403
api-1.json

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,2 @@
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_STORE_ID=""
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
# INNGEST_BASE_URL="http://localhost:8288"
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
# INNGEST_SIGNING_KEY="your-signing-key"
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
# INNGEST_JOBS_MAX_EVENTS=100
LEMON_SQUEEZY_STORE_ID=""

View File

@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "PORT=4000 tsx watch src/index.ts",
"build": "rimraf dist && tsc --project tsconfig.json",
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
@@ -12,27 +12,27 @@
"inngest": "3.40.1",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.7.6",
"@hono/zod-validator": "0.3.0",
"@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.4.5",
"hono": "^4.11.7",
"hono": "^4.7.10",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"redis": "4.7.0",
"zod": "^4.3.6"
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^24.4.0",
"@types/node": "^20.17.51",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"rimraf": "6.1.3",
"tsx": "^4.16.2",
"typescript": "^5.8.3"
},
"packageManager": "pnpm@10.22.0",
"packageManager": "pnpm@9.12.0",
"engines": {
"node": "^24.4.0",
"pnpm": ">=10.22.0"
"node": "^20.16.0",
"pnpm": ">=9.12.0"
}
}

View File

@@ -10,7 +10,6 @@ import {
type DeployJob,
deployJobSchema,
} from "./schema.js";
import { fetchDeploymentJobs } from "./service.js";
import { deploy } from "./utils.js";
const app = new Hono();
@@ -119,6 +118,7 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
200,
);
} catch (error) {
console.log("error", error);
logger.error("Failed to send deployment event", error);
return c.json(
{
@@ -176,29 +176,6 @@ app.get("/health", async (c) => {
return c.json({ status: "ok" });
});
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
app.get("/jobs", async (c) => {
const serverId = c.req.query("serverId");
if (!serverId) {
return c.json({ message: "serverId is required" }, 400);
}
try {
const rows = await fetchDeploymentJobs(serverId);
return c.json(rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("INNGEST_BASE_URL")) {
return c.json(
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
503,
);
}
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
return c.json([], 200);
}
});
// Serve Inngest functions endpoint
app.on(
["GET", "POST", "PUT"],

View File

@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]),
type: z.enum(["deploy"]),
applicationType: z.literal("application-preview"),
serverId: z.string().min(1),
}),

View File

@@ -1,239 +0,0 @@
import { logger } from "./logger.js";
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
const DEFAULT_MAX_EVENTS = 500;
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
type InngestEventRow = {
internal_id?: string;
accountID?: string;
environmentID?: string;
source?: string;
sourceID?: string | null;
/** RFC3339 timestamp API uses receivedAt, dev server may use received_at */
receivedAt?: string;
received_at?: string;
id: string;
name: string;
data: Record<string, unknown>;
user?: unknown;
ts: number;
v?: string | null;
metadata?: {
fetchedAt: string;
cachedUntil: string | null;
};
};
/** Run shape from GET /v1/events/{eventId}/runs the actual job execution */
type InngestRun = {
run_id: string;
event_id: string;
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
run_started_at?: string;
ended_at?: string | null;
output?: unknown;
// dev server / API may use different casing
run_started_at_ms?: number;
};
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
return ev.receivedAt ?? ev.received_at;
}
/** Map Inngest run status to BullMQ-style state for the UI */
function runStatusToState(
status: string,
): "pending" | "active" | "completed" | "failed" | "cancelled" {
const s = status.toLowerCase();
if (s === "running") return "active";
if (s === "completed") return "completed";
if (s === "failed") return "failed";
if (s === "cancelled") return "cancelled";
if (s === "queued") return "pending";
return "pending";
}
export const fetchInngestEvents = async () => {
const maxEvents = MAX_EVENTS;
const all: InngestEventRow[] = [];
let cursor: string | undefined;
do {
const params = new URLSearchParams({ limit: "100" });
if (cursor) {
params.set("cursor", cursor);
}
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
headers: {
Authorization: `Bearer ${signingKey}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
logger.warn("Inngest API error", {
status: res.status,
body: await res.text(),
});
break;
}
const body = (await res.json()) as {
data?: InngestEventRow[];
cursor?: string;
nextCursor?: string;
};
const data = Array.isArray(body.data) ? body.data : [];
all.push(...data);
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
const nextCursor =
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
cursor = hasMore ? nextCursor : undefined;
} while (cursor);
return all.slice(0, maxEvents);
};
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) runs are the actual jobs */
export const fetchInngestRunsForEvent = async (
eventId: string,
): Promise<InngestRun[]> => {
const res = await fetch(
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
{
headers: {
Authorization: `Bearer ${signingKey}`,
"Content-Type": "application/json",
},
},
);
if (!res.ok) {
logger.warn("Inngest runs API error", {
eventId,
status: res.status,
body: await res.text(),
});
return [];
}
const body = (await res.json()) as { data?: InngestRun[] };
return Array.isArray(body.data) ? body.data : [];
};
/** One row for the queue UI (BullMQ-compatible shape) */
export type DeploymentJobRow = {
id: string;
name: string;
data: Record<string, unknown>;
timestamp: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
state: string;
};
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
function buildDeploymentRowsFromRuns(
events: InngestEventRow[],
runsByEventId: Map<string, InngestRun[]>,
serverId: string,
): DeploymentJobRow[] {
const requested = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
const rows: DeploymentJobRow[] = [];
for (const ev of requested) {
const data = (ev.data ?? {}) as Record<string, unknown>;
const runs = runsByEventId.get(ev.id) ?? [];
if (runs.length === 0) {
// Queued: event received but no run yet
rows.push({
id: ev.id,
name: ev.name,
data,
timestamp: ev.ts,
processedOn: ev.ts,
finishedOn: undefined,
failedReason: undefined,
state: "pending",
});
continue;
}
for (const run of runs) {
const state = runStatusToState(run.status);
const runStartedMs =
run.run_started_at_ms ??
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
const endedMs = run.ended_at
? new Date(run.ended_at).getTime()
: undefined;
const failedReason =
state === "failed" &&
run.output &&
typeof run.output === "object" &&
"error" in run.output
? String((run.output as { error?: unknown }).error)
: undefined;
rows.push({
id: run.run_id,
name: ev.name,
data,
timestamp: runStartedMs,
processedOn: runStartedMs,
finishedOn:
state === "completed" || state === "failed" || state === "cancelled"
? endedMs
: undefined,
failedReason,
state,
});
}
}
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
}
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
export const fetchDeploymentJobs = async (
serverId: string,
): Promise<DeploymentJobRow[]> => {
if (!signingKey) {
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
return [];
}
if (!baseUrl) {
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
}
const events = await fetchInngestEvents();
const requestedForServer = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
// Limit to avoid too many run fetches
const toFetch = requestedForServer.slice(0, 50);
const runsByEventId = new Map<string, InngestRun[]>();
await Promise.all(
toFetch.map(async (ev) => {
const runs = await fetchInngestRunsForEvent(ev.id);
runsByEventId.set(ev.id, runs);
}),
);
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
};

View File

@@ -1,15 +1,14 @@
import {
deployApplication,
deployCompose,
deployPreviewApplication,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
deployRemoteApplication,
deployRemoteCompose,
deployRemotePreviewApplication,
rebuildRemoteApplication,
rebuildRemoteCompose,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import type { DeployJob } from "./schema.js";
import type { DeployJob } from "./schema";
export const deploy = async (job: DeployJob) => {
try {
@@ -17,13 +16,13 @@ export const deploy = async (job: DeployJob) => {
await updateApplicationStatus(job.applicationId, "running");
if (job.server) {
if (job.type === "redeploy") {
await rebuildApplication({
await rebuildRemoteApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployApplication({
await deployRemoteApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
@@ -37,13 +36,13 @@ export const deploy = async (job: DeployJob) => {
if (job.server) {
if (job.type === "redeploy") {
await rebuildCompose({
await rebuildRemoteCompose({
composeId: job.composeId,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployCompose({
await deployRemoteCompose({
composeId: job.composeId,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
@@ -55,15 +54,8 @@ export const deploy = async (job: DeployJob) => {
previewStatus: "running",
});
if (job.server) {
if (job.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild Preview Deployment",
descriptionLog: job.descriptionLog || "",
previewDeploymentId: job.previewDeploymentId,
});
} else if (job.type === "deploy") {
await deployPreviewApplication({
if (job.type === "deploy") {
await deployRemotePreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Preview Deployment",
descriptionLog: job.descriptionLog || "",

View File

@@ -1,6 +1,3 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
PORT=3000
NODE_ENV=development
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
HOSTINGER_API_KEY=
NODE_ENV=development

View File

@@ -1,2 +1,3 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
PORT=3000
NODE_ENV=production

1
apps/dokploy/.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.16.0

View File

@@ -1,243 +0,0 @@
import type { Registry } from "@dokploy/server";
import { getRegistryTag } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("getRegistryTag", () => {
// Helper to create a mock registry
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
return {
registryId: "test-registry-id",
registryName: "Test Registry",
username: "myuser",
password: "test-password",
registryUrl: "docker.io",
registryType: "cloud",
imagePrefix: null,
createdAt: new Date().toISOString(),
organizationId: "test-org-id",
...overrides,
};
};
describe("with username (no imagePrefix)", () => {
it("should handle simple image name without tag", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/myuser/nginx");
});
it("should handle image name with tag", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/myuser/nginx:latest");
});
it("should handle image name with username already present (no duplication)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("docker.io/myuser/myprivaterepo");
});
it("should handle image name with username and tag already present", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
// Should not duplicate username
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
});
it("should handle complex image name with username", () => {
const registry = createMockRegistry({ username: "siumauricio" });
const result = getRegistryTag(
registry,
"siumauricio/app-parse-multi-byte-port-e32uh7",
);
// Should not duplicate username
expect(result).toBe(
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
);
});
it("should handle image name with different username (should not duplicate)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
expect(result).toBe("docker.io/myuser/myprivaterepo");
});
it("should handle image name with full registry URL (no username)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "docker.io/nginx");
// Should add username since imageName doesn't have one
expect(result).toBe("docker.io/myuser/nginx");
});
it("should handle image name with custom registry URL and username", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
// Should not duplicate username even if registry URL is different
expect(result).toBe("docker.io/myuser/repo");
});
it("should handle image name with custom registry URL (different username)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
// Should use registry username, not the one in imageName
expect(result).toBe("docker.io/myuser/repo");
});
});
describe("with imagePrefix", () => {
it("should use imagePrefix instead of username", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/myorg/nginx");
});
it("should use imagePrefix with image tag", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/myorg/nginx:latest");
});
it("should handle imagePrefix with username already in image name", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
expect(result).toBe("docker.io/myorg/myprivaterepo");
});
it("should handle imagePrefix matching image name prefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "myorg/myprivaterepo");
// Should not duplicate prefix
expect(result).toBe("docker.io/myorg/myprivaterepo");
});
});
describe("without registryUrl", () => {
it("should work without registryUrl", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("myuser/nginx");
});
it("should work without registryUrl with imagePrefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
registryUrl: "",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("myorg/nginx");
});
it("should handle username already present without registryUrl", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("myuser/myprivaterepo");
});
});
describe("with custom registryUrl", () => {
it("should handle custom registry URL", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("ghcr.io/myuser/nginx");
});
it("should handle custom registry URL with imagePrefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("ghcr.io/myorg/nginx");
});
it("should handle custom registry URL with username already present", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
});
});
describe("edge cases", () => {
it("should handle empty image name", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "");
expect(result).toBe("docker.io/myuser/");
});
it("should handle image name with multiple slashes", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "org/suborg/repo");
expect(result).toBe("docker.io/myuser/repo");
});
it("should handle image name with username at different position", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "org/myuser/repo");
expect(result).toBe("docker.io/myuser/repo");
});
});
describe("special characters in username", () => {
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
const registry = createMockRegistry({
username: "robot$library+dokploy",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
});
it("should handle username with $ and other special characters", () => {
const registry = createMockRegistry({
username: "robot$test+app",
});
const result = getRegistryTag(registry, "myapp:latest");
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
});
it("should handle username with multiple $ symbols", () => {
const registry = createMockRegistry({
username: "user$name$test",
});
const result = getRegistryTag(registry, "app");
expect(result).toBe("docker.io/user$name$test/app");
});
it("should handle username with + and - symbols", () => {
const registry = createMockRegistry({
username: "robot+test-user",
});
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
});
});
});

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllProperties } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile1 = `
version: "3.8"
@@ -61,7 +61,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile1 = parse(`
const expectedComposeFile1 = load(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 1", () => {
const composeData = parse(composeFile1) as ComposeSpecification;
const composeData = load(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -185,7 +185,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile2 = parse(`
const expectedComposeFile2 = load(`
version: "3.8"
services:
@@ -243,7 +243,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 2", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -308,7 +308,7 @@ secrets:
file: ./service_secret.txt
`;
const expectedComposeFile3 = parse(`
const expectedComposeFile3 = load(`
version: "3.8"
services:
@@ -366,7 +366,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 3", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -420,7 +420,7 @@ volumes:
driver: local
`;
const expectedComposeFile = parse(`
const expectedComposeFile = load(`
version: "3.8"
services:
@@ -467,7 +467,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to all properties in Plausible compose file", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -23,7 +23,7 @@ configs:
`;
test("Add suffix to configs in root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -59,7 +59,7 @@ configs:
`;
test("Add suffix to multiple configs in root property", () => {
const composeData = parse(composeFileMultipleConfigs) as ComposeSpecification;
const composeData = load(composeFileMultipleConfigs) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -92,7 +92,7 @@ configs:
`;
test("Add suffix to configs with different properties in root property", () => {
const composeData = parse(
const composeData = load(
composeFileDifferentProperties,
) as ComposeSpecification;
@@ -137,7 +137,7 @@ configs:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigRoot = parse(`
const expectedComposeFileConfigRoot = load(`
version: "3.8"
services:
@@ -162,7 +162,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs in root property", () => {
const composeData = parse(composeFileConfigRoot) as ComposeSpecification;
const composeData = load(composeFileConfigRoot) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -3,8 +3,8 @@ import {
addSuffixToConfigsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -22,7 +22,7 @@ configs:
`;
test("Add suffix to configs in services", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -54,7 +54,7 @@ configs:
`;
test("Add suffix to configs in services with single config", () => {
const composeData = parse(
const composeData = load(
composeFileSingleServiceConfig,
) as ComposeSpecification;
@@ -108,7 +108,7 @@ configs:
`;
test("Add suffix to configs in services with multiple configs", () => {
const composeData = parse(
const composeData = load(
composeFileMultipleServicesConfigs,
) as ComposeSpecification;
@@ -157,7 +157,7 @@ services:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigServices = parse(`
const expectedComposeFileConfigServices = load(`
version: "3.8"
services:
@@ -182,7 +182,7 @@ services:
`) as ComposeSpecification;
test("Add suffix to configs in services", () => {
const composeData = parse(composeFileConfigServices) as ComposeSpecification;
const composeData = load(composeFileConfigServices) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -43,7 +43,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileCombinedConfigs = parse(`
const expectedComposeFileCombinedConfigs = load(`
version: "3.8"
services:
@@ -77,7 +77,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to all configs in root and services", () => {
const composeData = parse(composeFileCombinedConfigs) as ComposeSpecification;
const composeData = load(composeFileCombinedConfigs) as ComposeSpecification;
const suffix = "testhash";
@@ -122,7 +122,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileWithEnvAndExternal = parse(`
const expectedComposeFileWithEnvAndExternal = load(`
version: "3.8"
services:
@@ -159,7 +159,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs with environment and external", () => {
const composeData = parse(
const composeData = load(
composeFileWithEnvAndExternal,
) as ComposeSpecification;
@@ -200,7 +200,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileWithTemplateDriverAndLabels = parse(`
const expectedComposeFileWithTemplateDriverAndLabels = load(`
version: "3.8"
services:
@@ -231,7 +231,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs with template driver and labels", () => {
const composeData = parse(
const composeData = load(
composeFileWithTemplateDriverAndLabels,
) as ComposeSpecification;

View File

@@ -1,217 +0,0 @@
import type { Domain } from "@dokploy/server";
import { createDomainLabels } from "@dokploy/server";
import { describe, expect, it } from "vitest";
import { parse, stringify } from "yaml";
/**
* Regression tests for Traefik Host rule label format.
*
* These tests verify that the Host rule is generated with the correct format:
* - Host(`domain.com`) - with opening and closing parentheses
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
*
* Issue: https://github.com/Dokploy/dokploy/issues/3161
* The bug caused Host rules to be malformed as Host`domain.com`)
* (missing opening parenthesis) which broke all domain routing.
*/
describe("Host rule format regression tests", () => {
const baseDomain: Domain = {
host: "example.com",
port: 8080,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "",
domainType: "compose",
serviceName: "test-app",
domainId: "",
path: "/",
createdAt: "",
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
customEntrypoint: null,
middlewares: null,
};
describe("Host rule format validation", () => {
it("should generate Host rule with correct parentheses format", async () => {
const labels = await createDomainLabels("test-app", baseDomain, "web");
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
// Verify exact format: Host(`domain`)
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
// Ensure opening parenthesis is present after Host
expect(ruleLabel).toContain("Host(`example.com`)");
// Ensure it does NOT have the malformed format
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
});
it("should generate PathPrefix with correct parentheses format", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path: "/api" },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
// Verify PathPrefix format
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
expect(ruleLabel).toContain("PathPrefix(`/api`)");
// Ensure opening parenthesis is present
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
});
it("should generate combined Host and PathPrefix with correct format", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path: "/api/v1" },
"websecure",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
expect(ruleLabel).toBe(
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
);
});
});
describe("YAML serialization preserves Host rule format", () => {
it("should preserve Host rule format through YAML stringify/parse", async () => {
const labels = await createDomainLabels("test-app", baseDomain, "web");
const ruleLabel = labels.find((l) => l.includes(".rule="));
// Simulate compose file structure
const composeSpec = {
services: {
myapp: {
image: "nginx",
labels: labels,
},
},
};
// Stringify to YAML
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
// Parse back
const parsed = parse(yamlOutput) as typeof composeSpec;
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
l.includes(".rule="),
);
// Verify format is preserved
expect(parsedRuleLabel).toBe(ruleLabel);
expect(parsedRuleLabel).toContain("Host(`example.com`)");
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
});
it("should preserve complex rule format through YAML serialization", async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path: "/api", https: true },
"websecure",
);
const composeSpec = {
services: {
myapp: {
labels: labels,
},
},
};
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
const parsed = parse(yamlOutput) as typeof composeSpec;
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
l.includes(".rule="),
);
expect(parsedRuleLabel).toContain(
"Host(`example.com`) && PathPrefix(`/api`)",
);
});
});
describe("Edge cases for domain names", () => {
const domainCases = [
{ name: "simple domain", host: "example.com" },
{ name: "subdomain", host: "app.example.com" },
{ name: "deep subdomain", host: "api.v1.app.example.com" },
{ name: "numeric domain", host: "123.example.com" },
{ name: "hyphenated domain", host: "my-app.example-host.com" },
{ name: "localhost", host: "localhost" },
{ name: "IP address style", host: "192.168.1.100" },
];
for (const { name, host } of domainCases) {
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, host },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
// Verify parenthesis is present
expect(ruleLabel).toMatch(
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
);
});
}
});
describe("Multiple domains scenario", () => {
it("should generate correct format for both web and websecure entrypoints", async () => {
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
const websecureLabels = await createDomainLabels(
"test-app",
baseDomain,
"websecure",
);
const webRule = webLabels.find((l) => l.includes(".rule="));
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
// Both should have correct format
expect(webRule).toContain("Host(`example.com`)");
expect(websecureRule).toContain("Host(`example.com`)");
// Neither should have malformed format
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
});
});
describe("Special characters in paths", () => {
const pathCases = [
{ name: "simple path", path: "/api" },
{ name: "nested path", path: "/api/v1/users" },
{ name: "path with hyphen", path: "/api-v1" },
{ name: "path with underscore", path: "/api_v1" },
];
for (const { name, path } of pathCases) {
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
const labels = await createDomainLabels(
"test-app",
{ ...baseDomain, path },
"web",
);
const ruleLabel = labels.find((l) => l.includes(".rule="));
expect(ruleLabel).toBeDefined();
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
// Verify parenthesis is present
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
});
}
});
});

View File

@@ -7,7 +7,6 @@ describe("createDomainLabels", () => {
const baseDomain: Domain = {
host: "example.com",
port: 8080,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
@@ -22,7 +21,6 @@ describe("createDomainLabels", () => {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
};
it("should create basic labels for web entrypoint", async () => {
@@ -173,12 +171,12 @@ describe("createDomainLabels", () => {
"websecure",
);
// Web entrypoint with HTTPS should only have redirect
// Web entrypoint should have both middlewares with redirect first
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
);
// Websecure should have the addprefix middleware
// Websecure should only have the addprefix middleware
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
);
@@ -210,9 +208,9 @@ describe("createDomainLabels", () => {
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// Web router with HTTPS should only have redirect
// Should have middlewares in correct order: redirect, stripprefix, addprefix
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
);
});
@@ -242,259 +240,4 @@ describe("createDomainLabels", () => {
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should add single custom middleware to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
);
});
it("should add multiple custom middlewares to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file", "rate-limit@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
);
});
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
const combinedDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// Web router with HTTPS should only redirect, custom middlewares go on websecure
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(labels).not.toContain("auth@file");
});
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
const combinedDomain = {
...baseDomain,
path: "/api",
stripPath: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// stripprefix should come before custom middleware
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
);
});
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
const fullDomain = {
...baseDomain,
https: true,
path: "/api",
stripPath: true,
internalPath: "/hello",
middlewares: ["auth@file", "rate-limit@file"],
};
const webLabels = await createDomainLabels(appName, fullDomain, "web");
// Web router with HTTPS should only redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Middleware definitions should still be present (Traefik needs them registered)
expect(webLabels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// But they should NOT be attached to the router
expect(webLabels).not.toContain("stripprefix-test-app-1,");
expect(webLabels).not.toContain("auth@file");
expect(webLabels).not.toContain("rate-limit@file");
});
it("should include custom middlewares on websecure entrypoint", async () => {
const customMiddlewareDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const websecureLabels = await createDomainLabels(
appName,
customMiddlewareDomain,
"websecure",
);
// Websecure should have custom middleware but not redirect-to-https
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
);
expect(websecureLabels).not.toContain("redirect-to-https");
});
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
const domain = {
...baseDomain,
https: true,
middlewares: ["rate-limit@file", "auth@file"],
};
const webLabels = await createDomainLabels(appName, domain, "web");
// Web router with HTTPS should ONLY have redirect, not custom middlewares
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(webLabels).not.toContain("rate-limit@file");
expect(webLabels).not.toContain("auth@file");
});
it("should create basic labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{ ...baseDomain, customEntrypoint: "custom" },
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
]);
});
it("should create https labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
]);
});
it("should add stripPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
);
});
it("should add internalPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
);
});
it("should add path prefix in rule for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
},
"custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
);
});
it("should combine all middlewares for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
https: true,
certificateType: "letsencrypt",
},
"custom",
);
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
// Should not contain redirect-to-https since there's only one router
expect(middlewareLabel).toBeUndefined();
});
});

View File

@@ -4,30 +4,21 @@ import { describe, expect, it } from "vitest";
describe("addDokployNetworkToService", () => {
it("should add network to an empty array", () => {
const result = addDokployNetworkToService([]);
expect(result).toEqual(["dokploy-network", "default"]);
expect(result).toEqual(["dokploy-network"]);
});
it("should not add duplicate network to an array", () => {
const result = addDokployNetworkToService(["dokploy-network"]);
expect(result).toEqual(["dokploy-network", "default"]);
expect(result).toEqual(["dokploy-network"]);
});
it("should add network to an existing array with other networks", () => {
const result = addDokployNetworkToService(["other-network"]);
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
expect(result).toEqual(["other-network", "dokploy-network"]);
});
it("should add network to an object if networks is an object", () => {
const result = addDokployNetworkToService({ "other-network": {} });
expect(result).toEqual({
"other-network": {},
"dokploy-network": {},
default: {},
});
});
it("should not duplicate default network when already present", () => {
const result = addDokployNetworkToService(["default", "dokploy-network"]);
expect(result).toEqual(["default", "dokploy-network"]);
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
});
});

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -35,7 +35,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to networks root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -79,7 +79,7 @@ networks:
`;
test("Add suffix to advanced networks root property (2 TRY)", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -120,7 +120,7 @@ networks:
`;
test("Add suffix to networks with external properties", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -160,7 +160,7 @@ networks:
`;
test("Add suffix to networks with IPAM configurations", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -201,7 +201,7 @@ networks:
`;
test("Add suffix to networks with custom options", () => {
const composeData = parse(composeFile5) as ComposeSpecification;
const composeData = load(composeFile5) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -264,7 +264,7 @@ networks:
`;
test("Add suffix to networks with static suffix", () => {
const composeData = parse(composeFile6) as ComposeSpecification;
const composeData = load(composeFile6) as ComposeSpecification;
const suffix = "testhash";
@@ -273,7 +273,7 @@ test("Add suffix to networks with static suffix", () => {
}
const networks = addSuffixToNetworksRoot(composeData.networks, suffix);
const expectedComposeData = parse(
const expectedComposeData = load(
expectedComposeFile6,
) as ComposeSpecification;
expect(networks).toStrictEqual(expectedComposeData.networks);
@@ -292,8 +292,8 @@ networks:
dokploy-network:
`;
test("It shouldn't add suffix to dokploy-network", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
test("It shoudn't add suffix to dokploy-network", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -3,8 +3,8 @@ import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -23,7 +23,7 @@ services:
`;
test("Add suffix to networks in services", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -67,7 +67,7 @@ networks:
`;
test("Add suffix to networks in services with aliases", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -107,7 +107,7 @@ networks:
`;
test("Add suffix to networks in services (Object with simple networks)", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -153,7 +153,7 @@ networks:
`;
test("Add suffix to networks in services (combined case)", () => {
const composeData = parse(composeFileCombined) as ComposeSpecification;
const composeData = load(composeFileCombined) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -195,8 +195,8 @@ services:
- dokploy-network
`;
test("It shouldn't add suffix to dokploy-network in services", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
test("It shoudn't add suffix to dokploy-network in services", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -241,11 +241,11 @@ services:
dokploy-network:
aliases:
- apid
`;
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = parse(composeFile8) as ComposeSpecification;
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = load(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -5,8 +5,8 @@ import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombined = `
version: "3.8"
@@ -39,7 +39,7 @@ networks:
`;
test("Add suffix to networks in services and root (combined case)", () => {
const composeData = parse(composeFileCombined) as ComposeSpecification;
const composeData = load(composeFileCombined) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -89,7 +89,7 @@ test("Add suffix to networks in services and root (combined case)", () => {
expect(redisNetworks).not.toHaveProperty("backend");
});
const expectedComposeFile = parse(`
const expectedComposeFile = load(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ networks:
`);
test("Add suffix to networks in compose file", () => {
const composeData = parse(composeFileCombined) as ComposeSpecification;
const composeData = load(composeFileCombined) as ComposeSpecification;
const suffix = "testhash";
if (!composeData?.networks) {
@@ -156,7 +156,7 @@ networks:
driver: bridge
`;
const expectedComposeFile2 = parse(`
const expectedComposeFile2 = load(`
version: "3.8"
services:
@@ -182,7 +182,7 @@ networks:
`);
test("Add suffix to networks in compose file with external and internal networks", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -218,7 +218,7 @@ networks:
com.docker.network.bridge.enable_icc: "true"
`;
const expectedComposeFile3 = parse(`
const expectedComposeFile3 = load(`
version: "3.8"
services:
@@ -247,7 +247,7 @@ networks:
`);
test("Add suffix to networks in compose file with multiple services and complex network configurations", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -289,7 +289,7 @@ networks:
`;
const expectedComposeFile4 = parse(`
const expectedComposeFile4 = load(`
version: "3.8"
services:
@@ -326,7 +326,7 @@ networks:
`);
test("Expect don't add suffix to dokploy-network in compose file with multiple services and complex network configurations", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -23,7 +23,7 @@ secrets:
`;
test("Add suffix to secrets in root property", () => {
const composeData = parse(composeFileSecretsRoot) as ComposeSpecification;
const composeData = load(composeFileSecretsRoot) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -52,7 +52,7 @@ secrets:
`;
test("Add suffix to secrets in root property (Test 1)", () => {
const composeData = parse(composeFileSecretsRoot1) as ComposeSpecification;
const composeData = load(composeFileSecretsRoot1) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -84,7 +84,7 @@ secrets:
`;
test("Add suffix to secrets in root property (Test 2)", () => {
const composeData = parse(composeFileSecretsRoot2) as ComposeSpecification;
const composeData = load(composeFileSecretsRoot2) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {

View File

@@ -3,8 +3,8 @@ import {
addSuffixToSecretsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileSecretsServices = `
version: "3.8"
@@ -21,7 +21,7 @@ secrets:
`;
test("Add suffix to secrets in services", () => {
const composeData = parse(composeFileSecretsServices) as ComposeSpecification;
const composeData = load(composeFileSecretsServices) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -54,9 +54,7 @@ secrets:
`;
test("Add suffix to secrets in services (Test 1)", () => {
const composeData = parse(
composeFileSecretsServices1,
) as ComposeSpecification;
const composeData = load(composeFileSecretsServices1) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -95,9 +93,7 @@ secrets:
`;
test("Add suffix to secrets in services (Test 2)", () => {
const composeData = parse(
composeFileSecretsServices2,
) as ComposeSpecification;
const composeData = load(composeFileSecretsServices2) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllSecrets } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombinedSecrets = `
version: "3.8"
@@ -25,7 +25,7 @@ secrets:
file: ./app_secret.txt
`;
const expectedComposeFileCombinedSecrets = parse(`
const expectedComposeFileCombinedSecrets = load(`
version: "3.8"
services:
@@ -48,7 +48,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets", () => {
const composeData = parse(composeFileCombinedSecrets) as ComposeSpecification;
const composeData = load(composeFileCombinedSecrets) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -77,7 +77,7 @@ secrets:
file: ./cache_secret.txt
`;
const expectedComposeFileCombinedSecrets3 = parse(`
const expectedComposeFileCombinedSecrets3 = load(`
version: "3.8"
services:
@@ -99,9 +99,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets (3rd Case)", () => {
const composeData = parse(
composeFileCombinedSecrets3,
) as ComposeSpecification;
const composeData = load(composeFileCombinedSecrets3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -130,7 +128,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFileCombinedSecrets4 = parse(`
const expectedComposeFileCombinedSecrets4 = load(`
version: "3.8"
services:
@@ -152,9 +150,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets (4th Case)", () => {
const composeData = parse(
composeFileCombinedSecrets4,
) as ComposeSpecification;
const composeData = load(composeFileCombinedSecrets4) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -27,7 +27,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to service names with container_name in compose file", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -32,7 +32,7 @@ networks:
`;
test("Add suffix to service names with depends_on (array) in compose file", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -102,7 +102,7 @@ networks:
`;
test("Add suffix to service names with depends_on (object) in compose file", () => {
const composeData = parse(composeFile5) as ComposeSpecification;
const composeData = load(composeFile5) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -30,7 +30,7 @@ networks:
`;
test("Add suffix to service names with extends (string) in compose file", () => {
const composeData = parse(composeFile6) as ComposeSpecification;
const composeData = load(composeFile6) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -90,7 +90,7 @@ networks:
`;
test("Add suffix to service names with extends (object) in compose file", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const composeData = load(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -31,7 +31,7 @@ networks:
`;
test("Add suffix to service names with links in compose file", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -26,7 +26,7 @@ networks:
`;
test("Add suffix to service names in compose file", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -3,8 +3,8 @@ import {
addSuffixToAllServiceNames,
addSuffixToServiceNames,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombinedAllCases = `
version: "3.8"
@@ -38,7 +38,7 @@ networks:
driver: bridge
`;
const expectedComposeFile = parse(`
const expectedComposeFile = load(`
version: "3.8"
services:
@@ -71,9 +71,7 @@ networks:
`);
test("Add suffix to all service names in compose file", () => {
const composeData = parse(
composeFileCombinedAllCases,
) as ComposeSpecification;
const composeData = load(composeFileCombinedAllCases) as ComposeSpecification;
const suffix = "testhash";
@@ -133,7 +131,7 @@ networks:
driver: bridge
`;
const expectedComposeFile1 = parse(`
const expectedComposeFile1 = load(`
version: "3.8"
services:
@@ -178,7 +176,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 1", () => {
const composeData = parse(composeFile1) as ComposeSpecification;
const composeData = load(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -229,7 +227,7 @@ networks:
driver: bridge
`;
const expectedComposeFile2 = parse(`
const expectedComposeFile2 = load(`
version: "3.8"
services:
@@ -273,7 +271,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 2", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -324,7 +322,7 @@ networks:
driver: bridge
`;
const expectedComposeFile3 = parse(`
const expectedComposeFile3 = load(`
version: "3.8"
services:
@@ -368,7 +366,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 3", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -35,7 +35,7 @@ networks:
`;
test("Add suffix to service names with volumes_from in compose file", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -4,8 +4,8 @@ import {
addSuffixToVolumesRoot,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
services:
@@ -70,7 +70,7 @@ volumes:
driver: local
`;
const expectedDockerCompose = parse(`
const expectedDockerCompose = load(`
services:
mail:
image: bytemark/smtp
@@ -143,7 +143,7 @@ test("Generate random hash with 8 characters", () => {
// Docker compose needs unique names for services, volumes, networks and containers
// So base on a input which is a dockercompose file, it should replace the name with a hash and return a new dockercompose file
test("Add suffix to volumes root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -165,7 +165,7 @@ test("Add suffix to volumes root property", () => {
});
test("Expect to change the suffix in all the possible places", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -195,7 +195,7 @@ volumes:
mongo-data:
`;
const expectedDockerCompose2 = parse(`
const expectedDockerCompose2 = load(`
version: '3.8'
services:
app:
@@ -218,7 +218,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (2 Try)", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -248,7 +248,7 @@ volumes:
mongo-data:
`;
const expectedDockerCompose3 = parse(`
const expectedDockerCompose3 = load(`
version: '3.8'
services:
app:
@@ -271,7 +271,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (3 Try)", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -645,7 +645,7 @@ volumes:
db-config:
`;
const expectedDockerComposeComplex = parse(`
const expectedDockerComposeComplex = load(`
version: "3.8"
services:
studio:
@@ -1012,7 +1012,7 @@ volumes:
`);
test("Expect to change the suffix in all the possible places (4 Try)", () => {
const composeData = parse(composeFileComplex) as ComposeSpecification;
const composeData = load(composeFileComplex) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1065,7 +1065,7 @@ volumes:
db-data:
`;
const expectedDockerComposeExample1 = parse(`
const expectedDockerComposeExample1 = load(`
version: "3.8"
services:
web:
@@ -1111,7 +1111,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (5 Try)", () => {
const composeData = parse(composeFileExample1) as ComposeSpecification;
const composeData = load(composeFileExample1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1143,7 +1143,7 @@ volumes:
backrest-cache:
`;
const expectedDockerComposeBackrest = parse(`
const expectedDockerComposeBackrest = load(`
services:
backrest:
image: garethgeorge/backrest:v1.7.3
@@ -1168,7 +1168,7 @@ volumes:
`) as ComposeSpecification;
test("Should handle volume paths with subdirectories correctly", () => {
const composeData = parse(composeFileBackrest) as ComposeSpecification;
const composeData = load(composeFileBackrest) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -29,7 +29,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to volumes in root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -67,7 +67,7 @@ networks:
`;
test("Add suffix to volumes in root property (Case 2)", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -101,7 +101,7 @@ networks:
`;
test("Add suffix to volumes in root property (Case 3)", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -148,7 +148,7 @@ volumes:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFile4 = parse(`
const expectedComposeFile4 = load(`
version: "3.8"
services:
@@ -179,7 +179,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to volumes in root property", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -3,8 +3,8 @@ import {
addSuffixToVolumesInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -24,7 +24,7 @@ services:
`;
test("Add suffix to volumes declared directly in services", () => {
const composeData = parse(composeFile1) as ComposeSpecification;
const composeData = load(composeFile1) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -59,7 +59,7 @@ volumes:
`;
test("Add suffix to volumes declared directly in services (Case 2)", () => {
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllVolumes } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileTypeVolume = `
version: "3.8"
@@ -23,7 +23,7 @@ volumes:
driver: local
`;
const expectedComposeFileTypeVolume = parse(`
const expectedComposeFileTypeVolume = load(`
version: "3.8"
services:
@@ -44,7 +44,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to volumes with type: volume in services", () => {
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const suffix = "testhash";
@@ -73,7 +73,7 @@ volumes:
driver: local
`;
const expectedComposeFileTypeVolume1 = parse(`
const expectedComposeFileTypeVolume1 = load(`
version: "3.8"
services:
@@ -93,7 +93,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to mixed volumes in services", () => {
const composeData = parse(composeFileTypeVolume1) as ComposeSpecification;
const composeData = load(composeFileTypeVolume1) as ComposeSpecification;
const suffix = "testhash";
@@ -128,7 +128,7 @@ volumes:
device: /path/to/app/logs
`;
const expectedComposeFileTypeVolume2 = parse(`
const expectedComposeFileTypeVolume2 = load(`
version: "3.8"
services:
@@ -154,7 +154,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to complex volume configurations in services", () => {
const composeData = parse(composeFileTypeVolume2) as ComposeSpecification;
const composeData = load(composeFileTypeVolume2) as ComposeSpecification;
const suffix = "testhash";
@@ -218,7 +218,7 @@ volumes:
device: /path/to/shared/logs
`;
const expectedComposeFileTypeVolume3 = parse(`
const expectedComposeFileTypeVolume3 = load(`
version: "3.8"
services:
@@ -273,7 +273,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to complex nested volumes configuration in services", () => {
const composeData = parse(composeFileTypeVolume3) as ComposeSpecification;
const composeData = load(composeFileTypeVolume3) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -1,287 +0,0 @@
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
import * as builders from "@dokploy/server/utils/builders";
import * as notifications from "@dokploy/server/utils/notifications/build-success";
import * as execProcess from "@dokploy/server/utils/process/execAsync";
import * as gitProvider from "@dokploy/server/utils/providers/git";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}] as any),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
then: (resolve: (v: any) => void) => {
resolve([]);
},
} as any;
return chain;
};
return {
db: {
select: vi.fn(() => createChainableMock()),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
member: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn(),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/providers/git", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/providers/git")
>("@dokploy/server/utils/providers/git");
return {
...actual,
getGitCommitInfo: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
ExecError: class ExecError extends Error {},
}));
vi.mock("@dokploy/server/utils/builders", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/builders")
>("@dokploy/server/utils/builders");
return {
...actual,
mechanizeDockerContainer: vi.fn(),
getBuildCommand: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
import { db } from "@dokploy/server/db";
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
const createMockApplication = (overrides = {}) => ({
applicationId: "test-app-id",
name: "Test App",
appName: "test-app",
sourceType: "git" as const,
customGitUrl: "https://github.com/Dokploy/examples.git",
customGitBranch: "main",
customGitSSHKeyId: null,
buildType: "nixpacks" as const,
buildPath: "/astro",
env: "NODE_ENV=production",
serverId: null,
rollbackActive: false,
enableSubmodules: false,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
...overrides,
});
const createMockDeployment = () => ({
deploymentId: "deployment-id",
logPath: "/tmp/test-deployment.log",
});
describe("deployApplication - Command Generation Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
createMockDeployment() as any,
);
vi.mocked(execProcess.execAsync).mockResolvedValue({
stdout: "",
stderr: "",
} as any);
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
undefined as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
undefined as any,
);
vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
message: "test commit",
hash: "abc123",
});
vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
});
it("should generate correct git clone command for astro example", async () => {
const app = createMockApplication();
const command = await cloneGitRepository(app);
console.log(command);
expect(command).toContain("https://github.com/Dokploy/examples.git");
expect(command).not.toContain("--recurse-submodules");
expect(command).toContain("--branch main");
expect(command).toContain("--depth 1");
expect(command).toContain("git clone");
});
it("should generate git clone with submodules when enabled", async () => {
const app = createMockApplication({ enableSubmodules: true });
const command = await cloneGitRepository(app);
expect(command).toContain("--recurse-submodules");
expect(command).toContain("https://github.com/Dokploy/examples.git");
});
it("should verify nixpacks command is called with correct app", async () => {
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test deployment",
descriptionLog: "",
});
expect(builders.getBuildCommand).toHaveBeenCalledWith(
expect.objectContaining({
buildType: "nixpacks",
customGitUrl: "https://github.com/Dokploy/examples.git",
buildPath: "/astro",
}),
);
expect(execProcess.execAsync).toHaveBeenCalledWith(
expect.stringContaining("nixpacks build"),
);
});
it("should verify railpack command includes correct parameters", async () => {
const mockApp = createMockApplication({ buildType: "railpack" });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
mockApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
mockApp as any,
);
const mockRailpackCommand = "railpack prepare /path/to/app";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Railpack test",
descriptionLog: "",
});
expect(builders.getBuildCommand).toHaveBeenCalledWith(
expect.objectContaining({
buildType: "railpack",
}),
);
expect(execProcess.execAsync).toHaveBeenCalledWith(
expect.stringContaining("railpack prepare"),
);
});
it("should execute commands in correct order", async () => {
const mockNixpacksCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test",
descriptionLog: "",
});
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
expect(execCalls.length).toBeGreaterThan(0);
const fullCommand = execCalls[0]?.[0];
expect(fullCommand).toContain("set -e");
expect(fullCommand).toContain("git clone");
expect(fullCommand).toContain("nixpacks build");
});
it("should include log redirection in command", async () => {
const mockCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test",
descriptionLog: "",
});
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
const fullCommand = execCalls[0]?.[0];
expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
});
});

View File

@@ -1,490 +0,0 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { ApplicationNested } from "@dokploy/server";
import { paths } from "@dokploy/server/constants";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { format } from "date-fns";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
// Mock ONLY database and notifications
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain: any = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}]),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
then: (resolve: (v: any) => void) => {
resolve([]);
},
};
return chain;
};
return {
db: {
select: vi.fn(() => createChainableMock()),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
member: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
// NOT mocked (executed for real):
// - execAsync
// - cloneGitRepository
// - getBuildCommand
// - mechanizeDockerContainer (requires Docker Swarm)
import { db } from "@dokploy/server/db";
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
const createMockApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
applicationId: "test-app-id",
name: "Real Test App",
appName: `real-test-${Date.now()}`,
sourceType: "git" as const,
customGitUrl: "https://github.com/Dokploy/examples.git",
customGitBranch: "main",
customGitSSHKeyId: null,
customGitBuildPath: "/astro",
buildType: "nixpacks" as const,
env: "NODE_ENV=production",
serverId: null,
rollbackActive: false,
enableSubmodules: false,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
mounts: [],
security: [],
redirects: [],
ports: [],
registry: null,
...overrides,
}) as ApplicationNested;
const createMockDeployment = async (appName: string) => {
const { LOGS_PATH } = paths(false); // false = local, no remote server
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, appName, fileName);
// Actually create the log directory
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
return {
deploymentId: "deployment-id",
logPath: logFilePath,
};
};
async function cleanupDocker(appName: string) {
try {
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
} catch (error) {
console.log("Docker cleanup completed");
}
}
async function cleanupFiles(appName: string) {
try {
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
// Clean cloned code directories
const appPath = path.join(APPLICATIONS_PATH, appName);
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
// Clean logs for appName - removes entire folder
const logPath = path.join(LOGS_PATH, appName);
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
console.log(`✅ Cleaned up files and logs for ${appName}`);
} catch (error) {
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
}
}
describe(
"deployApplication - REAL Execution Tests",
() => {
let currentAppName: string;
let currentDeployment: any;
const allTestAppNames: string[] = [];
beforeEach(async () => {
vi.clearAllMocks();
currentAppName = `real-test-${Date.now()}`;
currentDeployment = await createMockDeployment(currentAppName);
allTestAppNames.push(currentAppName);
const mockApp = createMockApplication({ appName: currentAppName });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
mockApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
mockApp as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
currentDeployment as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
{} as any,
);
});
afterEach(async () => {
// ALWAYS cleanup, even if test failed or passed
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
// Clean current appName
try {
await cleanupDocker(currentAppName);
await cleanupFiles(currentAppName);
} catch (error) {
console.error("⚠️ Error cleaning current app:", error);
}
// Clean ALL test folders just in case
try {
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
await execAsync(
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
);
console.log("✅ Cleaned up all test artifacts");
} catch (error) {
console.error("⚠️ Error cleaning all artifacts:", error);
}
console.log("✅ Cleanup completed\n");
});
it(
"should REALLY clone git repo and build with nixpacks",
async () => {
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Nixpacks Test",
descriptionLog: "Testing real execution",
});
expect(result).toBe(true);
// Verify that Docker image was actually created
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
console.log("dockerImages", dockerImages);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Docker image created: ${currentAppName}`);
// Verify log exists and has content
expect(existsSync(currentDeployment.logPath)).toBe(true);
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Cloning");
expect(logContent).toContain("nixpacks");
console.log(`✅ Build log created with ${logContent.length} chars`);
// Verify update functions were called
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
"deployment-id",
"done",
);
},
REAL_TEST_TIMEOUT,
);
it.skip(
"should REALLY build with railpack (SKIPPED: requires special permissions)",
async () => {
const railpackAppName = `real-railpack-${Date.now()}`;
const railpackApp = createMockApplication({
appName: railpackAppName,
buildType: "railpack",
railpackVersion: "3",
});
currentAppName = railpackAppName;
allTestAppNames.push(railpackAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
railpackApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
railpackApp as any,
);
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Railpack Test",
descriptionLog: "",
});
expect(result).toBe(true);
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Railpack image created: ${currentAppName}`);
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("railpack");
console.log("✅ Railpack build completed");
},
REAL_TEST_TIMEOUT,
);
it(
"should handle REAL git clone errors",
async () => {
const errorAppName = `real-error-${Date.now()}`;
const errorApp = createMockApplication({
appName: errorAppName,
customGitUrl:
"https://github.com/invalid/nonexistent-repo-123456.git",
});
currentAppName = errorAppName;
allTestAppNames.push(errorAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
errorApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
errorApp as any,
);
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
await expect(
deployApplication({
applicationId: "test-app-id",
titleLog: "Real Error Test",
descriptionLog: "",
}),
).rejects.toThrow();
// Verify error status was called
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
"deployment-id",
"error",
);
// Verify log contains error
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent.toLowerCase()).toContain("error");
console.log("✅ Error handling verified");
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY clone with submodules when enabled",
async () => {
const submodulesAppName = `real-submodules-${Date.now()}`;
const submodulesApp = createMockApplication({
appName: submodulesAppName,
enableSubmodules: true,
});
currentAppName = submodulesAppName;
allTestAppNames.push(submodulesAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
submodulesApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
submodulesApp as any,
);
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Submodules Test",
descriptionLog: "",
});
expect(result).toBe(true);
// Verify deployment completed successfully
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Cloning");
expect(logContent.length).toBeGreaterThan(100);
console.log("✅ Submodules deployment completed");
// Verify image
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
expect(dockerImages.trim()).toBe(currentAppName);
},
REAL_TEST_TIMEOUT,
);
it(
"should verify REAL commit info extraction",
async () => {
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Commit Test",
descriptionLog: "",
});
// Verify updateDeployment was called with commit info
expect(deploymentService.updateDeployment).toHaveBeenCalled();
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
.calls[0];
// Real commit info should have title and hash
expect(updateCall?.[1]).toHaveProperty("title");
expect(updateCall?.[1]).toHaveProperty("description");
expect(updateCall?.[1]?.description).toContain("Commit:");
console.log(
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
);
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY build with Dockerfile",
async () => {
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
const dockerfileApp = createMockApplication({
appName: dockerfileAppName,
buildType: "dockerfile",
customGitBuildPath: "/deno",
dockerfile: "Dockerfile",
});
currentAppName = dockerfileAppName;
allTestAppNames.push(dockerfileAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
dockerfileApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
dockerfileApp as any,
);
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Dockerfile Test",
descriptionLog: "",
});
expect(result).toBe(true);
// Verify log
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Building");
expect(logContent).toContain(dockerfileAppName);
console.log("✅ Dockerfile build log verified");
// Verify image
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
console.log("dockerImages", dockerImages);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Docker image created: ${currentAppName}`);
},
REAL_TEST_TIMEOUT,
);
},
REAL_TEST_TIMEOUT,
);

View File

@@ -1,10 +1,5 @@
import { describe, expect, it } from "vitest";
import {
extractCommitMessage,
extractImageName,
extractImageTag,
extractImageTagFromRequest,
} from "@/pages/api/deploy/[refreshToken]";
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
describe("GitHub Webhook Skip CI", () => {
const mockGithubHeaders = {
@@ -83,14 +78,6 @@ describe("GitHub Webhook Skip CI", () => {
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
// Soft Serve
expect(
extractCommitMessage(
{ "x-softserve-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
});
it("should handle missing commit message", () => {
@@ -107,332 +94,5 @@ describe("GitHub Webhook Skip CI", () => {
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT",
);
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
"NEW COMMIT",
);
});
});
describe("GitHub Packages Docker Image Tag Extraction", () => {
it("should extract tag from container_metadata", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "v1.0.0",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:v1.0.0",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("v1.0.0");
});
it("should extract tag from package_url when container_metadata tag matches version", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "sha256:abc123...",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("latest");
});
it("should extract tag from package_url when container_metadata is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo:1.2.3",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("1.2.3");
});
it("should handle different tag formats in package_url", () => {
const headers = { "x-github-event": "registry_package" };
const testCases = [
{ url: "ghcr.io/owner/repo:latest", expected: "latest" },
{ url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" },
{ url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" },
{ url: "ghcr.io/owner/repo:dev", expected: "dev" },
];
for (const testCase of testCases) {
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: testCase.url,
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe(testCase.expected);
}
});
it("should return null for non-registry_package events", () => {
const headers = { "x-github-event": "push" };
const body = {
registry_package: {
package_version: {
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_version is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_url has no tag", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when package_url ends with colon (no tag)", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
package_url: "ghcr.io/owner/repo:",
container_metadata: {
tag: {
name: "",
digest: "sha256:abc123...",
},
},
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should return null when tag name is empty string", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBeNull();
});
it("should ignore tag if it matches the version (digest)", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
container_metadata: {
tag: {
name: "sha256:abc123...",
digest: "sha256:abc123...",
},
},
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const tag = extractImageTagFromRequest(headers, body);
expect(tag).toBe("latest");
});
it("should handle registry_package commit message with package_url", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
package_url: "ghcr.io/owner/repo:latest",
},
},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest");
});
it("should handle registry_package commit message when package_url is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {
package_version: {
version: "sha256:abc123...",
},
},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("Docker GHCR image pushed");
});
it("should handle registry_package commit message when package_version is missing", () => {
const headers = { "x-github-event": "registry_package" };
const body = {
registry_package: {},
};
const message = extractCommitMessage(headers, body);
expect(message).toBe("NEW COMMIT");
});
});
describe("Docker Image Name and Tag Extraction", () => {
describe("extractImageName", () => {
it("should return image name without tag", () => {
expect(extractImageName("my-image:latest")).toBe("my-image");
expect(extractImageName("my-image:1.0.0")).toBe("my-image");
expect(extractImageName("ghcr.io/owner/repo:latest")).toBe(
"ghcr.io/owner/repo",
);
});
it("should return full image name when no tag is present", () => {
expect(extractImageName("my-image")).toBe("my-image");
expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo");
});
it("should handle images with port numbers correctly", () => {
expect(extractImageName("registry:5000/image:tag")).toBe(
"registry:5000/image",
);
expect(extractImageName("localhost:5000/my-app:latest")).toBe(
"localhost:5000/my-app",
);
});
it("should handle complex image paths", () => {
expect(
extractImageName("myregistryhost:5000/fedora/httpd:version1.0"),
).toBe("myregistryhost:5000/fedora/httpd");
expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe(
"registry.example.com:8080/ns/app",
);
});
it("should return null for invalid inputs", () => {
expect(extractImageName(null)).toBeNull();
expect(extractImageName("")).toBeNull();
});
it("should handle edge cases with multiple colons", () => {
expect(extractImageName("image:tag:extra")).toBe("image:tag");
expect(extractImageName("registry:5000:invalid")).toBe("registry:5000");
});
});
describe("extractImageTag", () => {
it("should extract tag from image with tag", () => {
expect(extractImageTag("my-image:latest")).toBe("latest");
expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0");
expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3");
});
it("should return 'latest' when no tag is present", () => {
expect(extractImageTag("my-image")).toBe("latest");
expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest");
});
it("should handle complex image paths with tags", () => {
expect(
extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"),
).toBe("version1.0");
expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe(
"v1.2.3",
);
});
it("should return null for invalid inputs", () => {
expect(extractImageTag(null)).toBeNull();
expect(extractImageTag("")).toBeNull();
});
it("should handle edge cases with multiple colons", () => {
expect(extractImageTag("image:tag:extra")).toBe("extra");
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
});
it("should handle numeric tags", () => {
expect(extractImageTag("my-image:123")).toBe("123");
expect(extractImageTag("my-image:1")).toBe("1");
});
it("should return 'latest' for registry with port but no tag", () => {
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
"latest",
);
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
});
it("should extract tag from registry with port and tag", () => {
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
"v2.0",
);
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
"sha-abc123",
);
});
});
});

View File

@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import {
extractBranchName,
extractCommitMessage,
extractHash,
getProviderByHeader,
} from "@/pages/api/deploy/[refreshToken]";
describe("Soft Serve Webhook", () => {
const mockSoftServeHeaders = {
"x-softserve-event": "push",
};
const createMockBody = (message: string, hash: string, branch: string) => ({
event: "push",
ref: `refs/heads/${branch}`,
after: hash,
commits: [{ message: message }],
});
const message: string = "feat: add new feature";
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
const branch: string = "feat/add-new";
const goodWebhook = createMockBody(message, hash, branch);
it("should properly extract the provider name", () => {
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
});
it("should properly extract the commit message", () => {
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
message,
);
});
it("should properly extract hash", () => {
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
});
it("should properly extract branch name", () => {
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
});
it("should gracefully handle invalid webhook", () => {
expect(getProviderByHeader({})).toBeNull();
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
});
});

View File

@@ -6,7 +6,6 @@ import { paths } from "@dokploy/server/constants";
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const OUTPUT_BASE = "./__test__/drop/zips/output";
const { APPLICATIONS_PATH } = paths();
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual = await importOriginal();
@@ -14,10 +13,7 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
// @ts-ignore
...actual,
paths: () => ({
// @ts-ignore
...actual.paths(),
BASE_PATH: OUTPUT_BASE,
APPLICATIONS_PATH: OUTPUT_BASE,
APPLICATIONS_PATH: "./__test__/drop/zips/output",
}),
};
});
@@ -29,17 +25,11 @@ if (typeof window === "undefined") {
}
const baseApp: ApplicationNested = {
railpackVersion: "0.15.4",
railpackVersion: "0.2.2",
applicationId: "",
previewLabels: [],
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
args: [],
giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "",
@@ -47,22 +37,17 @@ const baseApp: ApplicationNested = {
giteaRepository: "",
cleanCache: false,
watchPaths: [],
rollbackRegistryId: "",
rollbackRegistry: null,
deployments: [],
enableSubmodules: false,
applicationStatus: "done",
triggerType: "push",
appName: "",
autoDeploy: true,
endpointSpecSwarm: null,
serverId: "",
registryUrl: "",
branch: null,
dockerBuildStage: "",
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
previewCertificateType: "none",
previewCustomCertResolver: null,
previewEnv: null,
@@ -73,7 +58,6 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
environment: {
env: "",
isDefault: false,
environmentId: "",
name: "",
createdAt: "",
@@ -89,7 +73,6 @@ const baseApp: ApplicationNested = {
},
},
buildArgs: null,
buildSecrets: null,
buildPath: "/",
gitlabPathNamespace: "",
buildType: "nixpacks",
@@ -120,7 +103,6 @@ const baseApp: ApplicationNested = {
environmentId: "",
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
@@ -151,180 +133,8 @@ const baseApp: ApplicationNested = {
username: null,
dockerContextPath: null,
rollbackActive: false,
stopGracePeriodSwarm: null,
ulimitsSwarm: null,
};
/**
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
*/
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
baseApp.appName = "ghsa-rce";
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
const cronPayload = "* * * * * root id\n";
const placeholder = "x".repeat(traversalEntry.length);
const zip = new AdmZip();
zip.addFile(
"package.json",
Buffer.from('{"name": "app", "version": "1.0.0"}'),
);
zip.addFile("index.js", Buffer.from('console.log("Application");'));
zip.addFile(placeholder, Buffer.from(cronPayload));
let buf = Buffer.from(zip.toBuffer());
buf = Buffer.from(
buf.toString("binary").split(placeholder).join(traversalEntry),
"binary",
);
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
/Path traversal detected.*resolved path escapes output directory/,
);
});
});
describe("security: existing symlink escape", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT write outside base when directory is a symlink", async () => {
const appName = "symlink-existing";
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// outside target (attacker wants to write here)
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
await fs.mkdir(outside, { recursive: true });
// attacker-controlled symlink inside project
await fs.symlink(outside, path.join(output, "logs"));
// zip looks totally harmless
const zip = new AdmZip();
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
// if vulnerable -> file exists outside sandbox
const escaped = await fs
.readFile(path.join(outside, "pwned.txt"), "utf8")
.then(() => true)
.catch(() => false);
expect(escaped).toBe(false);
});
});
describe("security: zip symlink entry blocked", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects zip containing real symlink entry", async () => {
const appName = "zip-symlink";
const zipBuffer = await fs.readFile(
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
);
const file = new File([zipBuffer as any], "exploit.zip");
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
/Dangerous node entries are not allowed/,
);
});
});
describe("unzipDrop path under output (no traversal)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
baseApp.appName = "cron-under-output";
const zip = new AdmZip();
zip.addFile(
"etc/cron.d/malicious-cron",
Buffer.from("* * * * * root id\n"),
);
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
const file = new File(
[zip.toBuffer() as unknown as ArrayBuffer],
"app.zip",
);
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
await unzipDrop(file, baseApp);
const content = await fs.readFile(
path.join(outputPath, "etc/cron.d/malicious-cron"),
"utf8",
);
expect(content).toBe("* * * * * root id\n");
});
});
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
const appName = "sandbox-escape";
const base = APPLICATIONS_PATH.replace("/applications", "");
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// attacker writes into traefik config inside base
const zip = new AdmZip();
zip.addFile(
"../../../traefik/dynamic/evil.yml",
Buffer.from("pwned: true"),
);
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
const exists = await fs
.readFile(escapedPath)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
});
describe("unzipDrop using real zip files", () => {
// const { APPLICATIONS_PATH } = paths();
beforeAll(async () => {
@@ -341,12 +151,14 @@ describe("unzipDrop using real zip files", () => {
try {
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
console.log(`Output Path: ${outputPath}`);
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "test.txt")).toBe(true);
} catch (err) {
console.log(err);
} finally {
}
});

View File

@@ -1 +0,0 @@
/etc/passwd

View File

@@ -1,294 +0,0 @@
import { describe, expect, it } from "vitest";
// Type definitions matching the project structure
type Environment = {
environmentId: string;
name: string;
isDefault: boolean;
};
type Project = {
projectId: string;
name: string;
environments: Environment[];
};
/**
* Helper function that selects the appropriate environment for a user
* This matches the logic used in search-command.tsx and show.tsx
*/
function selectAccessibleEnvironment(
project: Project | null | undefined,
): Environment | null {
if (!project || !project.environments || project.environments.length === 0) {
return null;
}
// Find default environment from accessible environments, or fall back to first accessible environment
const defaultEnvironment =
project.environments.find((environment) => environment.isDefault) ||
project.environments[0];
return defaultEnvironment || null;
}
describe("Environment Access Fallback", () => {
describe("selectAccessibleEnvironment", () => {
it("should return default environment when user has access to it", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-prod",
name: "production",
isDefault: true,
},
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-prod");
expect(result?.isDefault).toBe(true);
});
it("should return first accessible environment when user doesn't have access to default", () => {
// Simulating filtered environments (user only has access to development)
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
// Note: production is not in the list because user doesn't have access
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev");
expect(result?.name).toBe("development");
});
it("should return first environment when no default is marked but environments exist", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev");
});
it("should return null when project has no accessible environments", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [],
};
const result = selectAccessibleEnvironment(project);
expect(result).toBeNull();
});
it("should return null when project is null", () => {
const result = selectAccessibleEnvironment(null);
expect(result).toBeNull();
});
it("should return null when project is undefined", () => {
const result = selectAccessibleEnvironment(undefined);
expect(result).toBeNull();
});
it("should handle project with single accessible environment", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev");
});
it("should prioritize default environment even when it's not first in the array", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
{
environmentId: "env-prod",
name: "production",
isDefault: true,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-prod");
expect(result?.isDefault).toBe(true);
});
it("should handle multiple default environments by returning the first one found", () => {
// Edge case: multiple environments marked as default (shouldn't happen, but test it)
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-prod-1",
name: "production-1",
isDefault: true,
},
{
environmentId: "env-prod-2",
name: "production-2",
isDefault: true,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.isDefault).toBe(true);
// Should return the first default found
expect(result?.environmentId).toBe("env-prod-1");
});
it("should work correctly when user has access to multiple environments including default", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-prod",
name: "production",
isDefault: true,
},
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-prod");
expect(result?.isDefault).toBe(true);
});
it("should handle real-world scenario: user with only development access", () => {
// This simulates the exact bug we're fixing:
// User has access to development but not production (default)
// The filtered environments array only contains development
const project: Project = {
projectId: "proj-1",
name: "My Project",
environments: [
// Only development is accessible (production was filtered out)
{
environmentId: "env-dev-123",
name: "development",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev-123");
expect(result?.name).toBe("development");
// Should not be null even though it's not the default
});
});
describe("Environment selection edge cases", () => {
it("should handle project with environments property as undefined", () => {
const project = {
projectId: "proj-1",
name: "Test Project",
environments: undefined,
} as unknown as Project;
const result = selectAccessibleEnvironment(project);
expect(result).toBeNull();
});
it("should handle project with null environments array", () => {
const project = {
projectId: "proj-1",
name: "Test Project",
environments: null,
} as unknown as Project;
const result = selectAccessibleEnvironment(project);
expect(result).toBeNull();
});
});
});

View File

@@ -1,7 +1,4 @@
import {
prepareEnvironmentVariables,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/index";
import { prepareEnvironmentVariables } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
@@ -335,310 +332,4 @@ IS_DEV=\${{environment.DEVELOPMENT}}
"IS_DEV=0",
]);
});
it("handles environment variables with single quotes in values", () => {
const envWithSingleQuotes = `
ENV_VARIABLE='ENVITONME'NT'
ANOTHER_VAR='value with 'quotes' inside'
SIMPLE_VAR=no-quotes
`;
const serviceWithSingleQuotes = `
TEST_VAR=\${{environment.ENV_VARIABLE}}
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
SIMPLE=\${{environment.SIMPLE_VAR}}
`;
const resolved = prepareEnvironmentVariables(
serviceWithSingleQuotes,
"",
envWithSingleQuotes,
);
expect(resolved).toEqual([
"TEST_VAR=ENVITONME'NT",
"ANOTHER_TEST=value with 'quotes' inside",
"SIMPLE=no-quotes",
]);
});
});
describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
it("escapes single quotes in environment variable values", () => {
const serviceEnv = `
ENV_VARIABLE='ENVITONME'NT'
ANOTHER_VAR='value with 'quotes' inside'
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote should wrap these in double quotes
expect(resolved).toEqual([
`"ENV_VARIABLE=ENVITONME'NT"`,
`"ANOTHER_VAR=value with 'quotes' inside"`,
]);
});
it("escapes double quotes in environment variable values", () => {
const serviceEnv = `
MESSAGE="Hello "World""
QUOTED_PATH="/path/to/"file""
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote wraps in single quotes when there are double quotes inside
expect(resolved).toEqual([
`'MESSAGE=Hello "World"'`,
`'QUOTED_PATH=/path/to/"file"'`,
]);
});
it("escapes dollar signs in environment variable values", () => {
const serviceEnv = `
PRICE=$100
VARIABLE=$HOME/path
TEMPLATE=Hello $USER
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Dollar signs should be escaped to prevent variable expansion
for (const env of resolved) {
expect(env).toContain("$");
}
});
it("escapes backticks in environment variable values", () => {
const serviceEnv = `
COMMAND=\`echo "test"\`
NESTED=value with \`backticks\` inside
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
expect(resolved.length).toBe(2);
expect(resolved[0]).toContain("COMMAND");
expect(resolved[1]).toContain("NESTED");
});
it("handles environment variables with spaces", () => {
const serviceEnv = `
FULL_NAME="John Doe"
MESSAGE='Hello World'
SENTENCE=This is a test
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote uses single quotes for strings with spaces
expect(resolved).toEqual([
`'FULL_NAME=John Doe'`,
`'MESSAGE=Hello World'`,
`'SENTENCE=This is a test'`,
]);
});
it("handles environment variables with backslashes", () => {
const serviceEnv = `
WINDOWS_PATH=C:\\Users\\Documents
ESCAPED=value\\with\\backslashes
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// Backslashes should be properly escaped
expect(resolved.length).toBe(2);
for (const env of resolved) {
expect(env).toContain("\\");
}
});
it("handles simple environment variables without special characters", () => {
const serviceEnv = `
NODE_ENV=production
PORT=3000
DEBUG=true
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote escapes the = sign in some cases
expect(resolved).toEqual([
"NODE_ENV\\=production",
"PORT\\=3000",
"DEBUG\\=true",
]);
});
it("handles environment variables with mixed special characters", () => {
const serviceEnv = `
COMPLEX='value with "double" and 'single' quotes'
BASH_COMMAND=echo "$HOME" && echo 'test'
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// All should be escaped, none should throw errors
expect(resolved.length).toBe(3);
// Verify each can be safely used in shell
for (const env of resolved) {
expect(typeof env).toBe("string");
expect(env.length).toBeGreaterThan(0);
}
});
it("handles environment variables with newlines", () => {
const serviceEnv = `
MULTILINE="line1
line2
line3"
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(1);
expect(resolved[0]).toContain("MULTILINE");
});
it("handles empty environment variable values", () => {
const serviceEnv = `
EMPTY=
EMPTY_QUOTED=""
EMPTY_SINGLE=''
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
// shell-quote escapes the = sign for empty values
expect(resolved).toEqual([
"EMPTY\\=",
"EMPTY_QUOTED\\=",
"EMPTY_SINGLE\\=",
]);
});
it("handles environment variables with equals signs in values", () => {
const serviceEnv = `
EQUATION=a=b+c
CONNECTION_STRING=user=admin;password=test
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(2);
expect(resolved[0]).toContain("EQUATION");
expect(resolved[1]).toContain("CONNECTION_STRING");
});
it("resolves and escapes environment variables together", () => {
const projectEnv = `
BASE_URL=https://example.com
API_KEY='secret-key-with-quotes'
`;
const environmentEnv = `
ENV_NAME=production
DB_PASS='pa$$word'
`;
const serviceEnv = `
FULL_URL=\${{project.BASE_URL}}/api
AUTH_KEY=\${{project.API_KEY}}
ENVIRONMENT=\${{environment.ENV_NAME}}
DB_PASSWORD=\${{environment.DB_PASS}}
CUSTOM='value with 'quotes' inside'
`;
const resolved = prepareEnvironmentVariablesForShell(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(resolved.length).toBe(5);
// All resolved values should be properly escaped
for (const env of resolved) {
expect(typeof env).toBe("string");
}
});
it("handles environment variables with semicolons and ampersands", () => {
const serviceEnv = `
COMMAND=echo "test" && echo "test2"
MULTIPLE=cmd1; cmd2; cmd3
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
// These should be safely escaped to prevent command injection
for (const env of resolved) {
expect(typeof env).toBe("string");
expect(env.length).toBeGreaterThan(0);
}
});
it("handles environment variables with pipes and redirects", () => {
const serviceEnv = `
PIPE_COMMAND=cat file | grep test
REDIRECT=echo "test" > output.txt
BOTH=cat input.txt | grep pattern > output.txt
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
// Pipes and redirects should be safely quoted
expect(resolved[0]).toContain("PIPE_COMMAND");
expect(resolved[1]).toContain("REDIRECT");
expect(resolved[2]).toContain("BOTH");
// At least one should contain a pipe
const hasPipe = resolved.some((env) => env.includes("|"));
expect(hasPipe).toBe(true);
});
it("handles environment variables with parentheses and brackets", () => {
const serviceEnv = `
MATH=(a+b)*c
ARRAY=[1,2,3]
JSON={"key":"value"}
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
expect(resolved[0]).toContain("(");
expect(resolved[1]).toContain("[");
expect(resolved[2]).toContain("{");
});
it("handles very long environment variable values", () => {
const longValue = "a".repeat(10000);
const serviceEnv = `LONG_VAR=${longValue}`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(1);
expect(resolved[0]).toContain("LONG_VAR");
expect(resolved[0]?.length).toBeGreaterThan(10000);
});
it("handles special unicode characters in environment variables", () => {
const serviceEnv = `
EMOJI=Hello 🌍 World 🚀
CHINESE=你好世界
SPECIAL=café résumé naïve
`;
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
expect(resolved.length).toBe(3);
expect(resolved[0]).toContain("🌍");
expect(resolved[1]).toContain("你好");
expect(resolved[2]).toContain("café");
});
});

View File

@@ -1,184 +0,0 @@
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
PORT=3000
`;
const environmentEnv = `
NODE_ENV=development
API_URL=https://api.dev.example.com
REDIS_URL=redis://localhost:6379
DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123
`;
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
it("resolves environment variables correctly for Stack compose", () => {
const serviceEnv = `
FOO=\${{environment.NODE_ENV}}
BAR=\${{environment.API_URL}}
BAZ=test
`;
const result = getEnvironmentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
FOO: "development",
BAR: "https://api.dev.example.com",
BAZ: "test",
});
});
it("resolves both project and environment variables for Stack compose", () => {
const serviceEnv = `
ENVIRONMENT=\${{project.ENVIRONMENT}}
NODE_ENV=\${{environment.NODE_ENV}}
API_URL=\${{environment.API_URL}}
DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000
`;
const result = getEnvironmentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
ENVIRONMENT: "staging",
NODE_ENV: "development",
API_URL: "https://api.dev.example.com",
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/project_db",
SERVICE_PORT: "4000",
});
});
it("handles multiple environment references in single value for Stack compose", () => {
const multiRefEnv = `
HOST=localhost
PORT=5432
USERNAME=postgres
PASSWORD=secret123
`;
const serviceEnv = `
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
`;
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
expect(result).toEqual({
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
});
});
it("throws error for undefined environment variables in Stack compose", () => {
const serviceWithUndefined = `
UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`;
expect(() =>
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
});
it("allows service variables to override environment variables in Stack compose", () => {
const serviceOverrideEnv = `
NODE_ENV=production
API_URL=\${{environment.API_URL}}
`;
const result = getEnvironmentVariablesObject(
serviceOverrideEnv,
"",
environmentEnv,
);
expect(result).toEqual({
NODE_ENV: "production",
API_URL: "https://api.dev.example.com",
});
});
it("resolves complex references with project, environment, and service variables for Stack compose", () => {
const complexServiceEnv = `
FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`;
const result = getEnvironmentVariablesObject(
complexServiceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
FULL_DATABASE_URL:
"postgres://postgres:postgres@localhost:5432/project_db/dev_database",
API_ENDPOINT: "https://api.dev.example.com/staging/api",
SERVICE_NAME: "my-service",
COMPLEX_VAR: "my-service-development-staging",
});
});
it("maintains precedence: service > environment > project in Stack compose", () => {
const conflictingProjectEnv = `
NODE_ENV=production-project
API_URL=https://project.api.com
DATABASE_NAME=project_db
`;
const conflictingEnvironmentEnv = `
NODE_ENV=development-environment
API_URL=https://environment.api.com
DATABASE_NAME=env_db
`;
const serviceWithConflicts = `
NODE_ENV=service-override
PROJECT_ENV=\${{project.NODE_ENV}}
ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}}
`;
const result = getEnvironmentVariablesObject(
serviceWithConflicts,
conflictingProjectEnv,
conflictingEnvironmentEnv,
);
expect(result).toEqual({
NODE_ENV: "service-override",
PROJECT_ENV: "production-project",
ENV_VAR: "https://environment.api.com",
DB_NAME: "env_db",
});
});
it("handles empty environment variables in Stack compose", () => {
const serviceWithEmpty = `
SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}}
`;
const result = getEnvironmentVariablesObject(
serviceWithEmpty,
projectEnv,
"",
);
expect(result).toEqual({
SERVICE_VAR: "test",
PROJECT_VAR: "staging",
});
});
});

View File

@@ -1,144 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
overrides: Record<string, boolean> = {},
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
canCreateProjects: overrides.canCreateProjects ?? false,
canDeleteProjects: overrides.canDeleteProjects ?? false,
canCreateServices: overrides.canCreateServices ?? false,
canDeleteServices: overrides.canDeleteServices ?? false,
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
canAccessToDocker: overrides.canAccessToDocker ?? false,
canAccessToAPI: overrides.canAccessToAPI ?? false,
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { checkPermission } = await import("@dokploy/server/services/permission");
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("static roles bypass enterprise resources", () => {
it("owner bypasses deployment.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { deployment: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses backup.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { backup: ["create"] }),
).resolves.toBeUndefined();
});
it("member bypasses schedule.delete", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { schedule: ["delete"] }),
).resolves.toBeUndefined();
});
it("member bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, {
deployment: ["read"],
backup: ["create"],
domain: ["delete"],
}),
).resolves.toBeUndefined();
});
});
describe("static roles validate free-tier resources", () => {
it("owner passes project.create", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { project: ["create"] }),
).resolves.toBeUndefined();
});
it("member fails project.create (no legacy override)", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { project: ["create"] }),
).rejects.toThrow();
});
it("member passes service.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { service: ["read"] }),
).resolves.toBeUndefined();
});
it("member fails service.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { service: ["create"] }),
).rejects.toThrow();
});
});
describe("legacy boolean overrides for member", () => {
it("member passes project.create with canCreateProjects=true", async () => {
memberToReturn = mockMemberData("member", { canCreateProjects: true });
await expect(
checkPermission(ctx, { project: ["create"] }),
).resolves.toBeUndefined();
});
it("member passes docker.read with canAccessToDocker=true", async () => {
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
await expect(
checkPermission(ctx, { docker: ["read"] }),
).resolves.toBeUndefined();
});
it("member fails docker.read with canAccessToDocker=false", async () => {
memberToReturn = mockMemberData("member");
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
});
});

View File

@@ -1,79 +0,0 @@
import {
enterpriseOnlyResources,
statements,
} from "@dokploy/server/lib/access-control";
import { describe, expect, it } from "vitest";
const FREE_TIER_RESOURCES = [
"organization",
"member",
"invitation",
"team",
"ac",
"project",
"service",
"environment",
"docker",
"sshKeys",
"gitProviders",
"traefikFiles",
"api",
];
const ENTERPRISE_RESOURCES = [
"volume",
"deployment",
"envVars",
"projectEnvVars",
"environmentEnvVars",
"server",
"registry",
"certificate",
"backup",
"volumeBackup",
"schedule",
"domain",
"destination",
"notification",
"tag",
"logs",
"monitoring",
"auditLog",
];
describe("enterpriseOnlyResources set", () => {
it("contains all enterprise resources", () => {
for (const resource of ENTERPRISE_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(true);
}
});
it("does NOT contain free-tier resources", () => {
for (const resource of FREE_TIER_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(false);
}
});
it("every resource in statements is either free or enterprise", () => {
const allResources = Object.keys(statements);
for (const resource of allResources) {
const isFree = FREE_TIER_RESOURCES.includes(resource);
const isEnterprise = enterpriseOnlyResources.has(resource);
expect(isFree || isEnterprise).toBe(true);
}
});
it("free and enterprise sets don't overlap", () => {
for (const resource of FREE_TIER_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(false);
}
});
it("all statement resources are accounted for", () => {
const allResources = Object.keys(statements);
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
for (const resource of allResources) {
expect(categorized).toContain(resource);
}
});
});

View File

@@ -1,161 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
overrides: Record<string, boolean> = {},
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
canCreateProjects: overrides.canCreateProjects ?? false,
canDeleteProjects: overrides.canDeleteProjects ?? false,
canCreateServices: overrides.canCreateServices ?? false,
canDeleteServices: overrides.canDeleteServices ?? false,
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
canAccessToDocker: overrides.canAccessToDocker ?? false,
canAccessToAPI: overrides.canAccessToAPI ?? false,
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { resolvePermissions } = await import(
"@dokploy/server/services/permission"
);
const { enterpriseOnlyResources, statements } = await import(
"@dokploy/server/lib/access-control"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("enterprise resources for static roles", () => {
it("owner gets true for all enterprise resources", async () => {
memberToReturn = mockMemberData("owner");
const perms = await resolvePermissions(ctx);
for (const resource of enterpriseOnlyResources) {
const actions = statements[resource as keyof typeof statements];
for (const action of actions) {
expect((perms as any)[resource][action]).toBe(true);
}
}
});
it("admin gets true for all enterprise resources", async () => {
memberToReturn = mockMemberData("admin");
const perms = await resolvePermissions(ctx);
for (const resource of enterpriseOnlyResources) {
const actions = statements[resource as keyof typeof statements];
for (const action of actions) {
expect((perms as any)[resource][action]).toBe(true);
}
}
});
it("member gets true for service-level enterprise resources", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.deployment.read).toBe(true);
expect(perms.deployment.create).toBe(true);
expect(perms.domain.read).toBe(true);
expect(perms.backup.read).toBe(true);
expect(perms.logs.read).toBe(true);
expect(perms.monitoring.read).toBe(true);
});
it("member gets false for org-level enterprise resources", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(false);
expect(perms.registry.read).toBe(false);
expect(perms.certificate.read).toBe(false);
expect(perms.destination.read).toBe(false);
expect(perms.notification.read).toBe(false);
expect(perms.auditLog.read).toBe(false);
});
});
describe("free-tier resources for member", () => {
it("member gets service.read=true", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.service.read).toBe(true);
});
it("member gets project.create=false without legacy override", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(false);
});
it("member gets project.create=true with canCreateProjects", async () => {
memberToReturn = mockMemberData("member", { canCreateProjects: true });
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(true);
});
it("member gets docker.read=false without legacy override", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.docker.read).toBe(false);
});
it("member gets docker.read=true with canAccessToDocker", async () => {
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
const perms = await resolvePermissions(ctx);
expect(perms.docker.read).toBe(true);
});
});
describe("free-tier resources for owner", () => {
it("owner gets all free-tier permissions as true", async () => {
memberToReturn = mockMemberData("owner");
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(true);
expect(perms.project.delete).toBe(true);
expect(perms.service.create).toBe(true);
expect(perms.service.read).toBe(true);
expect(perms.service.delete).toBe(true);
expect(perms.docker.read).toBe(true);
expect(perms.traefikFiles.read).toBe(true);
expect(perms.traefikFiles.write).toBe(true);
});
});

View File

@@ -1,132 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
accessedServices: string[] = [],
accessedProjects: string[] = [],
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects,
accessedServices,
accessedEnvironments: [] as string[],
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
"@dokploy/server/services/permission"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("checkServicePermissionAndAccess", () => {
it("owner bypasses accessedServices check", async () => {
memberToReturn = mockMemberData("owner", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).resolves.toBeUndefined();
});
it("admin bypasses accessedServices check", async () => {
memberToReturn = mockMemberData("admin", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
backup: ["create"],
}),
).resolves.toBeUndefined();
});
it("member with access to service passes", async () => {
memberToReturn = mockMemberData("member", ["service-123"]);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).resolves.toBeUndefined();
});
it("member WITHOUT access to service fails", async () => {
memberToReturn = mockMemberData("member", ["other-service"]);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).rejects.toThrow("You don't have access to this service");
});
it("member with empty accessedServices fails", async () => {
memberToReturn = mockMemberData("member", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
domain: ["delete"],
}),
).rejects.toThrow("You don't have access to this service");
});
});
describe("checkServiceAccess", () => {
it("member with service access passes read check", async () => {
memberToReturn = mockMemberData("member", ["app-1"]);
await expect(
checkServiceAccess(ctx, "app-1", "read"),
).resolves.toBeUndefined();
});
it("member without service access fails read check", async () => {
memberToReturn = mockMemberData("member", []);
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
"You don't have access to this service",
);
});
it("owner bypasses all access checks", async () => {
memberToReturn = mockMemberData("owner", [], []);
await expect(
checkServiceAccess(ctx, "project-1", "create"),
).resolves.toBeUndefined();
});
});

View File

@@ -54,22 +54,4 @@ describe("processLogs", () => {
const result = parseRawConfig(entryWithWhitespace);
expect(result.data).toHaveLength(2);
});
it("should filter out Dokploy dashboard requests", () => {
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
// Test with only Dokploy dashboard entry - should be filtered out
const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
expect(resultOnlyDokploy.data).toHaveLength(0);
expect(resultOnlyDokploy.totalCount).toBe(0);
// Test with mixed entries - Dokploy should be filtered, others should remain
const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
const resultMixed = parseRawConfig(mixedEntries);
expect(resultMixed.data).toHaveLength(1);
expect(resultMixed.totalCount).toBe(1);
expect(resultMixed.data[0]?.ServiceName).not.toBe(
"dokploy-service-app@file",
);
});
});

View File

@@ -1,161 +0,0 @@
import type { ApplicationNested } from "@dokploy/server/utils/builders";
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
import { beforeEach, describe, expect, it, vi } from "vitest";
type MockCreateServiceOptions = {
TaskTemplate?: {
ContainerSpec?: {
StopGracePeriod?: number;
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>;
};
};
[key: string]: unknown;
};
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
vi.hoisted(() => {
const inspect = vi.fn<() => Promise<never>>();
const getService = vi.fn(() => ({ inspect }));
const createService = vi.fn<
(opts: MockCreateServiceOptions) => Promise<void>
>(async () => undefined);
const getRemoteDocker = vi.fn(async () => ({
getService,
createService,
}));
return {
inspectMock: inspect,
getServiceMock: getService,
createServiceMock: createService,
getRemoteDockerMock: getRemoteDocker,
};
});
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: getRemoteDockerMock,
}));
const createApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
appName: "test-app",
buildType: "dockerfile",
env: null,
mounts: [],
cpuLimit: null,
memoryLimit: null,
memoryReservation: null,
cpuReservation: null,
command: null,
ports: [],
sourceType: "docker",
dockerImage: "example:latest",
registry: null,
environment: {
project: { env: null },
env: null,
},
replicas: 1,
stopGracePeriodSwarm: 0,
ulimitsSwarm: null,
serverId: "server-id",
...overrides,
}) as unknown as ApplicationNested;
describe("mechanizeDockerContainer", () => {
beforeEach(() => {
inspectMock.mockReset();
inspectMock.mockRejectedValue(new Error("service not found"));
getServiceMock.mockClear();
createServiceMock.mockClear();
getRemoteDockerMock.mockClear();
getRemoteDockerMock.mockResolvedValue({
getService: getServiceMock,
createService: createServiceMock,
});
});
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0 });
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0] as
| [MockCreateServiceOptions]
| undefined;
if (!call) {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
"number",
);
});
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
const application = createApplication({ stopGracePeriodSwarm: null });
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0] as
| [MockCreateServiceOptions]
| undefined;
if (!call) {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
"StopGracePeriod",
);
});
it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => {
const ulimits = [
{ Name: "nofile", Soft: 10000, Hard: 20000 },
{ Name: "nproc", Soft: 4096, Hard: 8192 },
];
const application = createApplication({ ulimitsSwarm: ulimits });
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
if (!call) {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits);
});
it("omits Ulimits when ulimitsSwarm is null", async () => {
const application = createApplication({ ulimitsSwarm: null });
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
if (!call) {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
});
it("omits Ulimits when ulimitsSwarm is an empty array", async () => {
const application = createApplication({ ulimitsSwarm: [] });
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
if (!call) {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
});
});

View File

@@ -1,43 +0,0 @@
import { vi } from "vitest";
/**
* Mock the DB module so tests that import from @dokploy/server (barrel)
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
* Without this, loading the server barrel pulls in lib/auth and db, which
* connect to localhost:5432 and cause ECONNREFUSED.
*/
vi.mock("@dokploy/server/db", () => {
const chain = () => chain;
chain.set = () => chain;
chain.where = () => chain;
chain.values = () => chain;
chain.returning = () => Promise.resolve([{}]);
chain.from = () => chain;
chain.innerJoin = () => chain;
chain.then = (resolve: (value: unknown) => void) => {
resolve([]);
};
const tableMock = {
findFirst: vi.fn(() => Promise.resolve(undefined)),
findMany: vi.fn(() => Promise.resolve([])),
insert: vi.fn(() => Promise.resolve([{}])),
update: vi.fn(() => chain),
delete: vi.fn(() => chain),
};
return {
db: {
select: vi.fn(() => chain),
insert: vi.fn(() => ({
values: () => ({ returning: () => Promise.resolve([{}]) }),
})),
update: vi.fn(() => chain),
delete: vi.fn(() => chain),
query: new Proxy({} as Record<string, typeof tableMock>, {
get: () => tableMock,
}),
},
dbUrl: "postgres://mock:mock@localhost:5432/mock",
};
});

View File

@@ -494,49 +494,4 @@ describe("processTemplate", () => {
expect(result.mounts).toHaveLength(1);
});
});
describe("isolated deployment config", () => {
it("should default to isolated=true when not specified", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {},
},
};
expect(template.config.isolated).toBeUndefined();
// undefined !== false => isolatedDeployment = true
expect(template.config.isolated !== false).toBe(true);
});
it("should be isolated when isolated=true is explicitly set", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: true,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(true);
});
it("should disable isolated deployment when isolated=false", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: false,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(false);
});
});
});

View File

@@ -30,7 +30,9 @@ describe("helpers functions", () => {
const domain = processValue("${domain}", {}, mockSchema);
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
expect(
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
domain.endsWith(
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
),
).toBeTruthy();
});
});
@@ -159,50 +161,6 @@ describe("helpers functions", () => {
});
});
describe("Empty string variables", () => {
it("should replace variables with empty string values correctly", () => {
const variables = {
smtp_username: "",
smtp_password: "",
non_empty: "value",
};
const result1 = processValue("${smtp_username}", variables, mockSchema);
expect(result1).toBe("");
const result2 = processValue("${smtp_password}", variables, mockSchema);
expect(result2).toBe("");
const result3 = processValue("${non_empty}", variables, mockSchema);
expect(result3).toBe("value");
});
it("should not replace undefined variables", () => {
const variables = {
defined_var: "",
};
const result = processValue("${undefined_var}", variables, mockSchema);
expect(result).toBe("${undefined_var}");
});
it("should handle mixed empty and non-empty variables in template", () => {
const variables = {
smtp_address: "smtp.example.com",
smtp_port: "2525",
smtp_username: "",
smtp_password: "",
};
const template =
"SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}";
const result = processValue(template, variables, mockSchema);
expect(result).toBe(
"SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=",
);
});
});
describe("${jwt}", () => {
it("should generate a JWT string", () => {
const jwt = processValue("${jwt}", {}, mockSchema);
@@ -270,58 +228,5 @@ describe("helpers functions", () => {
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
);
});
it("should handle JWT payload with newlines and whitespace by trimming them", () => {
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
const expiry = iat + 3600;
const payloadWithNewlines = `{
"role": "anon",
"iss": "supabase",
"exp": ${expiry}
}
`;
const jwt = processValue(
"${jwt:secret:payload}",
{
secret: "mysecret",
payload: payloadWithNewlines,
},
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
jwtCheckHeader(parts[0]);
const decodedPayload = jwtBase64Decode(parts[1]);
expect(decodedPayload).toHaveProperty("role");
expect(decodedPayload.role).toEqual("anon");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload.iss).toEqual("supabase");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.exp).toEqual(expiry);
});
it("should handle JWT payload with leading and trailing whitespace", () => {
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
const expiry = iat + 3600;
const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `;
const jwt = processValue(
"${jwt:secret:payload}",
{
secret: "mysecret",
payload: payloadWithWhitespace,
},
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
jwtCheckHeader(parts[0]);
const decodedPayload = jwtBase64Decode(parts[1]);
expect(decodedPayload).toHaveProperty("role");
expect(decodedPayload.role).toEqual("service_role");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload.iss).toEqual("supabase");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.exp).toEqual(expiry);
});
});
});

View File

@@ -5,27 +5,19 @@ vi.mock("node:fs", () => ({
default: fs,
}));
import type { FileConfig } from "@dokploy/server";
import type { FileConfig, User } from "@dokploy/server";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
updateServerTraefik,
} from "@dokploy/server";
import type { webServerSettings } from "@dokploy/server/db/schema";
import { beforeEach, expect, test, vi } from "vitest";
type WebServerSettings = typeof webServerSettings.$inferSelect;
const baseSettings: WebServerSettings = {
id: "",
const baseAdmin: User = {
https: false,
certificateType: "none",
host: null,
serverIp: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
enablePaidFeatures: false,
allowImpersonation: false,
role: "user",
metricsConfig: {
containers: {
refreshRate: 20,
@@ -48,25 +40,33 @@ const baseSettings: WebServerSettings = {
urlCallback: "",
},
},
whitelabelingConfig: {
appName: null,
appDescription: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
loginLogoUrl: null,
supportUrl: null,
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
footerText: null,
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
createdAt: null,
createdAt: new Date(),
serverIp: null,
certificateType: "none",
host: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
serversQuantity: 0,
stripeCustomerId: "",
stripeSubscriptionId: "",
banExpires: new Date(),
banned: true,
banReason: "",
email: "",
expirationDate: "",
id: "",
isRegistered: false,
name: "",
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",
updatedAt: new Date(),
twoFactorEnabled: false,
};
beforeEach(() => {
@@ -84,7 +84,7 @@ test("Should read the configuration file", () => {
test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseSettings,
...baseAdmin,
https: true,
certificateType: "letsencrypt",
},
@@ -99,7 +99,7 @@ test("Should apply redirect-to-https", () => {
});
test("Should change only host when no certificate", () => {
updateServerTraefik(baseSettings, "example.com");
updateServerTraefik(baseAdmin, "example.com");
const config: FileConfig = loadOrCreateConfig("dokploy");
@@ -109,7 +109,7 @@ test("Should change only host when no certificate", () => {
test("Should not touch config without host", () => {
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
updateServerTraefik(baseSettings, null);
updateServerTraefik(baseAdmin, null);
const config: FileConfig = loadOrCreateConfig("dokploy");
@@ -118,14 +118,11 @@ test("Should not touch config without host", () => {
test("Should remove websecure if https rollback to http", () => {
updateServerTraefik(
{ ...baseSettings, certificateType: "letsencrypt" },
{ ...baseAdmin, certificateType: "letsencrypt" },
"example.com",
);
updateServerTraefik(
{ ...baseSettings, certificateType: "none" },
"example.com",
);
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
const config: FileConfig = loadOrCreateConfig("dokploy");

View File

@@ -3,28 +3,18 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
railpackVersion: "0.15.4",
railpackVersion: "0.2.2",
rollbackActive: false,
applicationId: "",
previewLabels: [],
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
giteaBuildPath: "",
giteaId: "",
args: [],
rollbackRegistryId: "",
rollbackRegistry: null,
deployments: [],
cleanCache: false,
applicationStatus: "done",
endpointSpecSwarm: null,
appName: "",
autoDeploy: true,
enableSubmodules: false,
@@ -35,10 +25,8 @@ const baseApp: ApplicationNested = {
registryUrl: "",
watchPaths: [],
buildArgs: null,
buildSecrets: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
triggerType: "push",
previewCertificateType: "none",
previewEnv: null,
@@ -51,7 +39,6 @@ const baseApp: ApplicationNested = {
environmentId: "",
environment: {
env: "",
isDefault: false,
environmentId: "",
name: "",
createdAt: "",
@@ -95,7 +82,6 @@ const baseApp: ApplicationNested = {
dropBuildPath: null,
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
@@ -125,8 +111,6 @@ const baseApp: ApplicationNested = {
updateConfigSwarm: null,
username: null,
dockerContextPath: null,
stopGracePeriodSwarm: null,
ulimitsSwarm: null,
};
const baseDomain: Domain = {
@@ -138,7 +122,6 @@ const baseDomain: Domain = {
https: false,
path: null,
port: null,
customEntrypoint: null,
serviceName: "",
composeId: "",
customCertResolver: null,
@@ -147,7 +130,6 @@ const baseDomain: Domain = {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
};
const baseRedirect: Redirect = {
@@ -267,80 +249,6 @@ test("Websecure entrypoint on https domain with redirect", async () => {
expect(router.middlewares).toContain("redirect-test-1");
});
/** Custom Middlewares */
test("Web entrypoint with single custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with multiple custom middlewares", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
expect(router.middlewares).toContain("rate-limit@file");
});
test("Web entrypoint on https domain with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"web",
);
// Should only have HTTPS redirect - custom middleware applies on websecure
expect(router.middlewares).toContain("redirect-to-https");
expect(router.middlewares).not.toContain("auth@file");
});
test("Websecure entrypoint with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"websecure",
);
// Should have custom middleware but not HTTPS redirect
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with redirect and custom middleware", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
},
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
// Should have both redirect middleware and custom middleware
expect(router.middlewares).toContain("redirect-test-1");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with empty middlewares array", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, middlewares: [] },
"web",
);
// Should behave same as no middlewares - no redirect for http
expect(router.middlewares).not.toContain("redirect-to-https");
});
/** Certificates */
test("CertificateType on websecure entrypoint", async () => {
@@ -352,175 +260,3 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint on http domain", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, customEntrypoint: "custom" },
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls).toBeUndefined();
});
test("Custom entrypoint on https domain", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
"custom",
);
expect(router.rule).toContain("PathPrefix(`/api`)");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(router.middlewares).toContain("stripprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(router.middlewares).toContain("addprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
path: "/public",
stripPath: true,
internalPath: "/app/v2",
},
"web",
);
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
expect(stripIndex).toBeGreaterThanOrEqual(0);
expect(addIndex).toBeGreaterThanOrEqual(0);
expect(stripIndex).toBeLessThan(addIndex);
});
test("Custom entrypoint with https and custom cert resolver", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "custom",
customCertResolver: "myresolver",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls?.certResolver).toBe("myresolver");
});
test("Custom entrypoint without https should not have tls", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: false,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls).toBeUndefined();
});
/** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "тест.рф" },
"web",
);
// тест.рф in punycode is xn--e1aybc.xn--p1ai
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
expect(router.rule).not.toContain("тест.рф");
});
test("ASCII domain remains unchanged", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "example.com" },
"web",
);
expect(router.rule).toContain("Host(`example.com`)");
});
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "сайт.ru" },
"web",
);
// сайт in punycode is xn--80aswg
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
expect(router.rule).not.toContain("сайт");
});
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "app.тест.рф" },
"web",
);
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
expect(router.rule).not.toContain("тест.рф");
});

View File

@@ -7,22 +7,13 @@ export default defineConfig({
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
pool: "forks",
setupFiles: [path.resolve(__dirname, "setup.ts")],
},
define: {
"process.env": {
NODE: "test",
GITHUB_CLIENT_ID: "test",
GITHUB_CLIENT_SECRET: "test",
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
},
},
plugins: [
tsconfigPaths({
projects: [path.resolve(__dirname, "../tsconfig.json")],
}),
],
plugins: [tsconfigPaths()],
resolve: {
alias: {
"@dokploy/server": path.resolve(

View File

@@ -1,81 +0,0 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const BASE = "/base";
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/constants")>();
return {
...actual,
paths: () => ({
...actual.paths(),
BASE_PATH: BASE,
LOGS_PATH: `${BASE}/logs`,
APPLICATIONS_PATH: `${BASE}/applications`,
}),
};
});
// Import after mock so paths() uses our BASE
const { readValidDirectory } = await import("@dokploy/server");
describe("readValidDirectory (path traversal)", () => {
it("returns true when directory is exactly BASE_PATH", () => {
expect(readValidDirectory(BASE)).toBe(true);
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
});
it("returns true when directory is under BASE_PATH", () => {
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
});
it("returns false for path traversal escaping base (absolute)", () => {
expect(readValidDirectory("/etc/passwd")).toBe(false);
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
expect(readValidDirectory("/tmp/outside")).toBe(false);
});
it("returns false when resolved path escapes base via ..", () => {
// Resolved: /etc/passwd (outside /base)
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
});
it("returns true when .. stays within base", () => {
// e.g. /base/logs/../applications -> /base/applications (still under /base)
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
});
it("accepts serverId for remote base path", () => {
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
expect(readValidDirectory(BASE, "server-1")).toBe(true);
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
});
it("returns false for null/undefined-like paths that resolve outside", () => {
// Paths that might resolve to cwd or root
expect(readValidDirectory(".")).toBe(false);
expect(readValidDirectory("..")).toBe(false);
});
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
expect(readValidDirectory(`${BASE}/`)).toBe(true);
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
});
it("returns false when path looks like base but is a sibling or prefix", () => {
expect(readValidDirectory("/base-evil")).toBe(false);
expect(readValidDirectory("/bas")).toBe(false);
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
});
it("returns false for empty string (resolves to cwd)", () => {
expect(readValidDirectory("")).toBe(false);
});
});

View File

@@ -1,132 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isValidContainerId,
isValidSearch,
isValidSince,
isValidTail,
} from "../../server/wss/utils";
describe("isValidTail (docker-container-logs)", () => {
it("accepts valid numeric tail values", () => {
expect(isValidTail("0")).toBe(true);
expect(isValidTail("1")).toBe(true);
expect(isValidTail("100")).toBe(true);
expect(isValidTail("10000")).toBe(true);
});
it("rejects tail above 10000", () => {
expect(isValidTail("10001")).toBe(false);
expect(isValidTail("99999")).toBe(false);
});
it("rejects non-numeric tail", () => {
expect(isValidTail("")).toBe(false);
expect(isValidTail("abc")).toBe(false);
expect(isValidTail("10a")).toBe(false);
expect(isValidTail("-1")).toBe(false);
});
it("rejects command injection payloads in tail", () => {
expect(isValidTail("10; whoami; #")).toBe(false);
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
expect(isValidTail("$(id)")).toBe(false);
expect(isValidTail("`id`")).toBe(false);
expect(isValidTail("100\nid")).toBe(false);
expect(isValidTail("100 && id")).toBe(false);
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
});
});
describe("isValidSince (docker-container-logs)", () => {
it("accepts 'all'", () => {
expect(isValidSince("all")).toBe(true);
});
it("accepts valid duration format (number + s|m|h|d)", () => {
expect(isValidSince("5s")).toBe(true);
expect(isValidSince("10m")).toBe(true);
expect(isValidSince("1h")).toBe(true);
expect(isValidSince("2d")).toBe(true);
expect(isValidSince("0s")).toBe(true);
expect(isValidSince("999d")).toBe(true);
});
it("rejects invalid duration format", () => {
expect(isValidSince("")).toBe(false);
expect(isValidSince("5")).toBe(false);
expect(isValidSince("s")).toBe(false);
expect(isValidSince("5x")).toBe(false);
expect(isValidSince("5sec")).toBe(false);
expect(isValidSince("5 m")).toBe(false);
});
it("rejects command injection payloads in since", () => {
expect(isValidSince("5s; whoami")).toBe(false);
expect(isValidSince("all; id")).toBe(false);
expect(isValidSince("1m$(id)")).toBe(false);
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
});
});
describe("isValidSearch (docker-container-logs)", () => {
it("accepts empty string", () => {
expect(isValidSearch("")).toBe(true);
});
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
expect(isValidSearch("error")).toBe(true);
expect(isValidSearch("foo bar")).toBe(true);
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
expect(isValidSearch("")).toBe(true);
});
it("rejects strings longer than 500 chars", () => {
expect(isValidSearch("a".repeat(501))).toBe(false);
expect(isValidSearch("a".repeat(500))).toBe(true);
});
it("rejects control characters and non-printable", () => {
expect(isValidSearch("foo\nbar")).toBe(false);
expect(isValidSearch("foo\rbar")).toBe(false);
expect(isValidSearch("\x00")).toBe(false);
expect(isValidSearch("a\x19b")).toBe(false);
});
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
// Double-quoted context (SSH line 99): $ and ` execute
expect(isValidSearch("$(whoami)")).toBe(false);
expect(isValidSearch("`id`")).toBe(false);
expect(isValidSearch("$(id)")).toBe(false);
// Single-quoted context (local line 153): ' breaks out
expect(isValidSearch("'$(whoami)'")).toBe(false);
expect(isValidSearch("error'")).toBe(false);
expect(isValidSearch("'; whoami; #")).toBe(false);
// Other shell-metacharacters
expect(isValidSearch("error; id")).toBe(false);
expect(isValidSearch("a|b")).toBe(false);
expect(isValidSearch('error"')).toBe(false);
expect(isValidSearch("a&b")).toBe(false);
});
});
describe("isValidContainerId (docker-container-logs)", () => {
it("accepts valid hex container IDs", () => {
expect(isValidContainerId("a".repeat(12))).toBe(true);
expect(isValidContainerId("abc123def456")).toBe(true);
expect(isValidContainerId("a".repeat(64))).toBe(true);
});
it("accepts valid container names", () => {
expect(isValidContainerId("my-container")).toBe(true);
expect(isValidContainerId("app_1")).toBe(true);
expect(isValidContainerId("service.name")).toBe(true);
});
it("rejects command injection in container ID", () => {
expect(isValidContainerId("dummy; whoami")).toBe(false);
expect(isValidContainerId("$(id)")).toBe(false);
expect(isValidContainerId("`id`")).toBe(false);
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
});
});

View File

@@ -1,152 +1,305 @@
import { Settings } from "lucide-react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { HelpCircle, Settings } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import {
EndpointSpecForm,
HealthCheckForm,
LabelsForm,
ModeForm,
NetworkForm,
PlacementForm,
RestartPolicyForm,
RollbackConfigForm,
StopGracePeriodForm,
UpdateConfigForm,
} from "./swarm-forms";
import { api } from "@/utils/api";
type MenuItem = {
id: string;
label: string;
description: string;
docDescription: string;
const HealthCheckSwarmSchema = z
.object({
Test: z.array(z.string()).optional(),
Interval: z.number().optional(),
Timeout: z.number().optional(),
StartPeriod: z.number().optional(),
Retries: z.number().optional(),
})
.strict();
const RestartPolicySwarmSchema = z
.object({
Condition: z.string().optional(),
Delay: z.number().optional(),
MaxAttempts: z.number().optional(),
Window: z.number().optional(),
})
.strict();
const PreferenceSchema = z
.object({
Spread: z.object({
SpreadDescriptor: z.string(),
}),
})
.strict();
const PlatformSchema = z
.object({
Architecture: z.string(),
OS: z.string(),
})
.strict();
const PlacementSwarmSchema = z
.object({
Constraints: z.array(z.string()).optional(),
Preferences: z.array(PreferenceSchema).optional(),
MaxReplicas: z.number().optional(),
Platforms: z.array(PlatformSchema).optional(),
})
.strict();
const UpdateConfigSwarmSchema = z
.object({
Parallelism: z.number(),
Delay: z.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.number().optional(),
MaxFailureRatio: z.number().optional(),
Order: z.string(),
})
.strict();
const ReplicatedSchema = z
.object({
Replicas: z.number().optional(),
})
.strict();
const ReplicatedJobSchema = z
.object({
MaxConcurrent: z.number().optional(),
TotalCompletions: z.number().optional(),
})
.strict();
const ServiceModeSwarmSchema = z
.object({
Replicated: ReplicatedSchema.optional(),
Global: z.object({}).optional(),
ReplicatedJob: ReplicatedJobSchema.optional(),
GlobalJob: z.object({}).optional(),
})
.strict();
const NetworkSwarmSchema = z.array(
z
.object({
Target: z.string().optional(),
Aliases: z.array(z.string()).optional(),
DriverOpts: z.object({}).optional(),
})
.strict(),
);
const LabelsSwarmSchema = z.record(z.string());
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
return z
.string()
.transform((str, ctx) => {
if (str === null || str === "") {
return null;
}
try {
return JSON.parse(str);
} catch {
ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
return z.NEVER;
}
})
.superRefine((data, ctx) => {
if (data === null) {
return;
}
if (Object.keys(data).length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Object cannot be empty",
});
return;
}
const parseResult = schema.safeParse(data);
if (!parseResult.success) {
for (const error of parseResult.error.issues) {
const path = error.path.join(".");
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${path} ${error.message}`,
});
}
}
});
};
const menuItems: MenuItem[] = [
{
id: "health-check",
label: "Health Check",
description: "Configure health check settings",
docDescription:
"Configure HEALTHCHECK to test a container's health. Determines if a container is healthy by running a command inside the container. Test, Interval, Timeout, StartPeriod, and Retries control health monitoring.",
},
{
id: "restart-policy",
label: "Restart Policy",
description: "Configure restart policy",
docDescription:
"Configure the restart policy for containers in the service. Condition (none, on-failure, any), Delay (nanoseconds between restarts), MaxAttempts, and Window control restart behavior.",
},
{
id: "placement",
label: "Placement",
description: "Configure placement constraints",
docDescription:
"Control which nodes service tasks can be scheduled on. Constraints (node.id==xyz), Preferences (spread.node.labels.zone), MaxReplicas, and Platforms specify task placement rules.",
},
{
id: "update-config",
label: "Update Config",
description: "Configure update strategy",
docDescription:
"Configure how the service should be updated. Parallelism (tasks updated simultaneously), Delay, FailureAction (pause, continue, rollback), Monitor, MaxFailureRatio, and Order (stop-first, start-first) control updates.",
},
{
id: "rollback-config",
label: "Rollback Config",
description: "Configure rollback strategy",
docDescription:
"Configure automated rollback on update failure. Uses same parameters as UpdateConfig: Parallelism, Delay, FailureAction, Monitor, MaxFailureRatio, and Order.",
},
{
id: "mode",
label: "Mode",
description: "Configure service mode",
docDescription:
"Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).",
},
{
id: "network",
label: "Network",
description: "Configure network attachments",
docDescription:
"Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.",
},
{
id: "labels",
label: "Labels",
description: "Configure service labels",
docDescription:
"Add metadata to services using labels. Labels are key-value pairs (e.g., com.example.foo=bar) for organizing and filtering services.",
},
{
id: "stop-grace-period",
label: "Stop Grace Period",
description: "Configure stop grace period",
docDescription:
"Time to wait before forcefully killing a container. Specified in nanoseconds (e.g., 10000000000 = 10 seconds). Allows containers to shutdown gracefully.",
},
{
id: "endpoint-spec",
label: "Endpoint Spec",
description: "Configure endpoint specification",
docDescription:
"Configure endpoint mode for service discovery. Mode 'vip' (virtual IP - default) uses a single virtual IP. Mode 'dnsrr' (DNS round-robin) returns DNS entries for all tasks.",
},
];
const addSwarmSettings = z.object({
healthCheckSwarm: createStringToJSONSchema(HealthCheckSwarmSchema).nullable(),
restartPolicySwarm: createStringToJSONSchema(
RestartPolicySwarmSchema,
).nullable(),
placementSwarm: createStringToJSONSchema(PlacementSwarmSchema).nullable(),
updateConfigSwarm: createStringToJSONSchema(
UpdateConfigSwarmSchema,
).nullable(),
rollbackConfigSwarm: createStringToJSONSchema(
UpdateConfigSwarmSchema,
).nullable(),
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
});
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
interface Props {
id: string;
type:
| "application"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres"
| "redis";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const AddSwarmSettings = ({ id, type }: Props) => {
const [activeMenu, setActiveMenu] = useState<string>("health-check");
const [open, setOpen] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isError, error, isLoading } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<AddSwarmSettings>({
defaultValues: {
healthCheckSwarm: null,
restartPolicySwarm: null,
placementSwarm: null,
updateConfigSwarm: null,
rollbackConfigSwarm: null,
modeSwarm: null,
labelsSwarm: null,
networkSwarm: null,
},
resolver: zodResolver(addSwarmSettings),
});
useEffect(() => {
if (data) {
form.reset({
healthCheckSwarm: data.healthCheckSwarm
? JSON.stringify(data.healthCheckSwarm, null, 2)
: null,
restartPolicySwarm: data.restartPolicySwarm
? JSON.stringify(data.restartPolicySwarm, null, 2)
: null,
placementSwarm: data.placementSwarm
? JSON.stringify(data.placementSwarm, null, 2)
: null,
updateConfigSwarm: data.updateConfigSwarm
? JSON.stringify(data.updateConfigSwarm, null, 2)
: null,
rollbackConfigSwarm: data.rollbackConfigSwarm
? JSON.stringify(data.rollbackConfigSwarm, null, 2)
: null,
modeSwarm: data.modeSwarm
? JSON.stringify(data.modeSwarm, null, 2)
: null,
labelsSwarm: data.labelsSwarm
? JSON.stringify(data.labelsSwarm, null, 2)
: null,
networkSwarm: data.networkSwarm
? JSON.stringify(data.networkSwarm, null, 2)
: null,
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: AddSwarmSettings) => {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
healthCheckSwarm: data.healthCheckSwarm,
restartPolicySwarm: data.restartPolicySwarm,
placementSwarm: data.placementSwarm,
updateConfigSwarm: data.updateConfigSwarm,
rollbackConfigSwarm: data.rollbackConfigSwarm,
modeSwarm: data.modeSwarm,
labelsSwarm: data.labelsSwarm,
networkSwarm: data.networkSwarm,
})
.then(async () => {
toast.success("Swarm settings updated");
refetch();
})
.catch(() => {
toast.error("Error updating the swarm settings");
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary" className="cursor-pointer w-fit">
<Settings className="size-4 text-muted-foreground" />
Swarm Settings
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-[85vh]">
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Swarm Settings</DialogTitle>
<DialogDescription>
Configure swarm settings for your service.
Update certain settings using a json object.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div>
<AlertBlock type="info">
Changing settings such as placements may cause the logs/monitoring,
@@ -154,67 +307,485 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
</AlertBlock>
</div>
<div className="flex gap-4 h-[60vh] py-4">
{/* Left Column - Menu */}
<div className="w-64 flex-shrink-0 border-r pr-4 overflow-y-auto">
<nav className="space-y-1">
<TooltipProvider>
{menuItems.map((item) => (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setActiveMenu(item.id)}
className={cn(
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors",
activeMenu === item.id
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
)}
<Form {...form}>
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4 relative mt-4"
>
<FormField
control={form.control}
name="healthCheckSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormLabel>Health Check</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<div className="font-medium">{item.label}</div>
<div className="text-xs opacity-80">
{item.description}
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">{item.docDescription}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</nav>
</div>
<code>
<pre>
{`{
Test?: string[] | undefined;
Interval?: number | undefined;
Timeout?: number | undefined;
StartPeriod?: number | undefined;
Retries?: number | undefined;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Right Column - Form */}
<div className="flex-1 overflow-y-auto">
{activeMenu === "health-check" && (
<HealthCheckForm id={id} type={type} />
)}
{activeMenu === "restart-policy" && (
<RestartPolicyForm id={id} type={type} />
)}
{activeMenu === "placement" && (
<PlacementForm id={id} type={type} />
)}
{activeMenu === "update-config" && (
<UpdateConfigForm id={id} type={type} />
)}
{activeMenu === "rollback-config" && (
<RollbackConfigForm id={id} type={type} />
)}
{activeMenu === "mode" && <ModeForm id={id} type={type} />}
{activeMenu === "network" && <NetworkForm id={id} type={type} />}
{activeMenu === "labels" && <LabelsForm id={id} type={type} />}
{activeMenu === "stop-grace-period" && (
<StopGracePeriodForm id={id} type={type} />
)}
{activeMenu === "endpoint-spec" && (
<EndpointSpecForm id={id} type={type} />
)}
</div>
</div>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
"Interval" : 10000,
"Timeout" : 10000,
"StartPeriod" : 10000,
"Retries" : 10
}`}
className="h-[12rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="restartPolicySwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormLabel>Restart Policy</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Condition?: string | undefined;
Delay?: number | undefined;
MaxAttempts?: number | undefined;
Window?: number | undefined;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Condition" : "on-failure",
"Delay" : 10000,
"MaxAttempts" : 10,
"Window" : 10000
} `}
className="h-[12rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="placementSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormLabel>Placement</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Constraints?: string[] | undefined;
Preferences?: Array<{ Spread: { SpreadDescriptor: string } }> | undefined;
MaxReplicas?: number | undefined;
Platforms?:
| Array<{
Architecture: string;
OS: string;
}>
| undefined;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Constraints" : ["node.role==manager"],
"Preferences" : [{
"Spread" : {
"SpreadDescriptor" : "node.labels.region"
}
}],
"MaxReplicas" : 10,
"Platforms" : [{
"Architecture" : "amd64",
"OS" : "linux"
}]
} `}
className="h-[21rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="updateConfigSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormLabel>Update Config</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Parallelism?: number;
Delay?: number | undefined;
FailureAction?: string | undefined;
Monitor?: number | undefined;
MaxFailureRatio?: number | undefined;
Order: string;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Parallelism" : 1,
"Delay" : 10000,
"FailureAction" : "continue",
"Monitor" : 10000,
"MaxFailureRatio" : 10,
"Order" : "start-first"
}`}
className="h-[21rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="rollbackConfigSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormLabel>Rollback Config</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Parallelism?: number;
Delay?: number | undefined;
FailureAction?: string | undefined;
Monitor?: number | undefined;
MaxFailureRatio?: number | undefined;
Order: string;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Parallelism" : 1,
"Delay" : 10000,
"FailureAction" : "continue",
"Monitor" : 10000,
"MaxFailureRatio" : 10,
"Order" : "start-first"
}`}
className="h-[17rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="modeSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormLabel>Mode</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="center"
side="bottom"
>
<code>
<pre>
{`{
Replicated?: { Replicas?: number | undefined } | undefined;
Global?: {} | undefined;
ReplicatedJob?:
| {
MaxConcurrent?: number | undefined;
TotalCompletions?: number | undefined;
}
| undefined;
GlobalJob?: {} | undefined;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Replicated" : {
"Replicas" : 1
},
"Global" : {},
"ReplicatedJob" : {
"MaxConcurrent" : 1,
"TotalCompletions" : 1
},
"GlobalJob" : {}
}`}
className="h-[17rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="networkSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormLabel>Network</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`[
{
"Target" : string | undefined;
"Aliases" : string[] | undefined;
"DriverOpts" : { [key: string]: string } | undefined;
}
]`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`[
{
"Target" : "dokploy-network",
"Aliases" : ["dokploy-network"],
"DriverOpts" : {
"com.docker.network.driver.mtu" : "1500",
"com.docker.network.driver.host_binding" : "true",
"com.docker.network.driver.mtu" : "1500",
"com.docker.network.driver.host_binding" : "true"
}
}
]`}
className="h-[20rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="labelsSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormLabel>Labels</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
[name: string]: string;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"com.example.app.name" : "my-app",
"com.example.app.version" : "1.0.0"
}`}
className="h-[20rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
<Button
isLoading={isLoading}
form="hook-form-add-permissions"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@@ -1,4 +1,4 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { Server } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
interface Props {
id: string;
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
const AddRedirectSchema = z.object({
const AddRedirectchema = z.object({
replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string().optional(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
type AddCommand = z.infer<typeof AddRedirectchema>;
export const ShowClusterSettings = ({ id, type }: Props) => {
const queryMap = {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -65,16 +65,15 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const { data: registries } = api.registry.all.useQuery();
const mutationMap = {
application: () => api.application.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isPending } = mutationMap[type]
const { mutateAsync, isLoading } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
@@ -87,7 +86,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
: {}),
replicas: data?.replicas || 1,
},
resolver: zodResolver(AddRedirectSchema),
resolver: zodResolver(AddRedirectchema),
});
useEffect(() => {
@@ -106,11 +105,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId: id || "",
mariadbId: id || "",
mongoId: id || "",
mysqlId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
...(type === "application"
? {
registryId:
@@ -237,7 +236,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
)}
<div className="flex justify-end">
<Button isLoading={isPending} type="submit" className="w-fit">
<Button isLoading={isLoading} type="submit" className="w-fit">
Save
</Button>
</div>

View File

@@ -1,164 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const endpointSpecFormSchema = z.object({
Mode: z.string().optional(),
});
interface EndpointSpecFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(endpointSpecFormSchema),
defaultValues: {
Mode: undefined,
},
});
useEffect(() => {
if (data?.endpointSpecSwarm) {
const es = data.endpointSpecSwarm;
form.reset({
Mode: es.Mode,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof endpointSpecFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
formData.Mode !== undefined &&
formData.Mode !== null &&
formData.Mode !== "";
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
endpointSpecSwarm: hasAnyValue ? formData : null,
});
toast.success("Endpoint spec updated successfully");
refetch();
} catch {
toast.error("Error updating endpoint spec");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Mode"
render={({ field }) => (
<FormItem>
<FormLabel>Mode</FormLabel>
<FormDescription>Endpoint mode (vip or dnsrr)</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select endpoint mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="vip">VIP (Virtual IP)</SelectItem>
<SelectItem value="dnsrr">DNS Round Robin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Mode: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Endpoint Spec
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,280 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
export const healthCheckFormSchema = z.object({
Test: z.array(z.string()).optional(),
Interval: z.coerce.number().optional(),
Timeout: z.coerce.number().optional(),
StartPeriod: z.coerce.number().optional(),
Retries: z.coerce.number().optional(),
});
interface HealthCheckFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(healthCheckFormSchema),
defaultValues: {
Test: [],
Interval: undefined,
Timeout: undefined,
StartPeriod: undefined,
Retries: undefined,
},
});
const testCommands = form.watch("Test") || [];
useEffect(() => {
if (data?.healthCheckSwarm) {
const hc = data.healthCheckSwarm;
form.reset({
Test: hc.Test || [],
Interval: hc.Interval,
Timeout: hc.Timeout,
StartPeriod: hc.StartPeriod,
Retries: hc.Retries,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof healthCheckFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
(formData.Test && formData.Test.length > 0) ||
formData.Interval !== undefined ||
formData.Timeout !== undefined ||
formData.StartPeriod !== undefined ||
formData.Retries !== undefined;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
healthCheckSwarm: hasAnyValue ? formData : null,
});
toast.success("Health check updated successfully");
refetch();
} catch {
toast.error("Error updating health check");
} finally {
setIsLoading(false);
}
};
const addTestCommand = () => {
form.setValue("Test", [...testCommands, ""]);
};
const updateTestCommand = (index: number, value: string) => {
const newCommands = [...testCommands];
newCommands[index] = value;
form.setValue("Test", newCommands);
};
const removeTestCommand = (index: number) => {
form.setValue(
"Test",
testCommands.filter((_: string, i: number) => i !== index),
);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Test Commands</FormLabel>
<FormDescription>
Command to run for health check (e.g., ["CMD-SHELL", "curl -f
http://localhost:3000/health"])
</FormDescription>
<div className="space-y-2 mt-2">
{testCommands.map((cmd: string, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={cmd}
onChange={(e) => updateTestCommand(index, e.target.value)}
placeholder={
index === 0
? "CMD-SHELL"
: "curl -f http://localhost:3000/health"
}
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeTestCommand(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addTestCommand}
>
Add Command
</Button>
</div>
</div>
<FormField
control={form.control}
name="Interval"
render={({ field }) => (
<FormItem>
<FormLabel>Interval (nanoseconds)</FormLabel>
<FormDescription>
Time between health checks (e.g., 10000000000 for 10 seconds)
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Timeout"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (nanoseconds)</FormLabel>
<FormDescription>
Maximum time to wait for health check response
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="StartPeriod"
render={({ field }) => (
<FormItem>
<FormLabel>Start Period (nanoseconds)</FormLabel>
<FormDescription>
Initial grace period before health checks begin
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Retries"
render={({ field }) => (
<FormItem>
<FormLabel>Retries</FormLabel>
<FormDescription>
Number of consecutive failures needed to consider container
unhealthy
</FormDescription>
<FormControl>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Test: [],
Interval: undefined,
Timeout: undefined,
StartPeriod: undefined,
Retries: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Health Check
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,11 +0,0 @@
export { EndpointSpecForm } from "./endpoint-spec-form";
export { HealthCheckForm } from "./health-check-form";
export { LabelsForm } from "./labels-form";
export { ModeForm } from "./mode-form";
export { NetworkForm } from "./network-form";
export { PlacementForm } from "./placement-form";
export { RestartPolicyForm } from "./restart-policy-form";
export { RollbackConfigForm } from "./rollback-config-form";
export { StopGracePeriodForm } from "./stop-grace-period-form";
export { UpdateConfigForm } from "./update-config-form";
export { filterEmptyValues, hasValues } from "./utils";

View File

@@ -1,210 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
export const labelsFormSchema = z.object({
labels: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional(),
});
interface LabelsFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(labelsFormSchema),
defaultValues: {
labels: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "labels",
});
useEffect(() => {
if (data?.labelsSwarm && typeof data.labelsSwarm === "object") {
const labelEntries = Object.entries(data.labelsSwarm).map(
([key, value]) => ({
key,
value: value as string,
}),
);
form.reset({ labels: labelEntries });
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof labelsFormSchema>) => {
setIsLoading(true);
try {
const labelsObject =
formData.labels?.reduce(
(acc, { key, value }) => {
if (key && value) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
) || {};
// If no labels, send null to clear the database
const labelsToSend =
Object.keys(labelsObject).length > 0 ? labelsObject : null;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
labelsSwarm: labelsToSend,
});
toast.success("Labels updated successfully");
refetch();
} catch {
toast.error("Error updating labels");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Labels</FormLabel>
<FormDescription>
Add key-value labels to your service
</FormDescription>
<div className="space-y-2 mt-2">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<FormField
control={form.control}
name={`labels.${index}.key`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="com.example.app.name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`labels.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="my-app" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: "", value: "" })}
>
Add Label
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({ labels: [] });
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Labels
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,213 +0,0 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface ModeFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const ModeForm = ({ id, type }: ModeFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
defaultValues: {
type: undefined,
Replicas: undefined,
},
});
const modeType = form.watch("type");
useEffect(() => {
if (data?.modeSwarm) {
const mode = data.modeSwarm;
if (mode.Replicated) {
form.reset({
type: "Replicated",
Replicas: mode.Replicated.Replicas,
});
} else if (mode.Global) {
form.reset({
type: "Global",
Replicas: undefined,
});
}
}
}, [data, form]);
const onSubmit = async (formData: any) => {
setIsLoading(true);
try {
// If no type is selected, send null to clear the database
if (!formData.type) {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
modeSwarm: null,
});
toast.success("Mode updated successfully");
refetch();
setIsLoading(false);
return;
}
const modeData =
formData.type === "Replicated"
? {
Replicated: {
Replicas:
formData.Replicas !== undefined && formData.Replicas !== ""
? Number(formData.Replicas)
: undefined,
},
}
: { Global: {} };
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
modeSwarm: modeData,
});
toast.success("Mode updated successfully");
refetch();
} catch {
toast.error("Error updating mode");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Mode Type</FormLabel>
<FormDescription>
Choose between replicated or global service mode
</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select mode type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Replicated">Replicated</SelectItem>
<SelectItem value="Global">Global</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{modeType === "Replicated" && (
<FormField
control={form.control}
name="Replicas"
render={({ field }) => (
<FormItem>
<FormLabel>Replicas</FormLabel>
<FormDescription>Number of replicas to run</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
type: undefined,
Replicas: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Mode
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,323 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const driverOptEntrySchema = z.object({
key: z.string(),
value: z.string(),
});
export const networkFormSchema = z.object({
networks: z
.array(
z.object({
Target: z.string().optional(),
Aliases: z.string().optional(),
DriverOptsEntries: z.array(driverOptEntrySchema).optional(),
}),
)
.optional(),
});
interface NetworkFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<z.infer<typeof networkFormSchema>>({
resolver: zodResolver(networkFormSchema),
defaultValues: {
networks: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "networks",
});
useEffect(() => {
if (data?.networkSwarm && Array.isArray(data.networkSwarm)) {
const networkEntries = data.networkSwarm.map((network) => ({
Target: network.Target || "",
Aliases: network.Aliases?.join(", ") || "",
DriverOptsEntries: network.DriverOpts
? Object.entries(network.DriverOpts).map(([key, value]) => ({
key,
value: value ?? "",
}))
: [],
}));
form.reset({ networks: networkEntries });
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof networkFormSchema>) => {
setIsLoading(true);
try {
const networksArray =
formData.networks
?.filter((network) => network.Target)
.map((network) => {
const entries = (network.DriverOptsEntries ?? []).filter(
(e) => e.key.trim() !== "",
);
const driverOpts =
entries.length > 0
? Object.fromEntries(
entries.map((e) => [e.key.trim(), e.value]),
)
: undefined;
return {
Target: network.Target,
Aliases: network.Aliases
? network.Aliases.split(",").map((alias) => alias.trim())
: undefined,
DriverOpts: driverOpts,
};
}) || [];
// If no networks, send null to clear the database
const networksToSend = networksArray.length > 0 ? networksArray : null;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
networkSwarm: networksToSend,
});
toast.success("Network configuration updated successfully");
refetch();
} catch {
toast.error("Error updating network configuration");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Networks</FormLabel>
<FormDescription>
Configure network attachments for your service
</FormDescription>
<div className="space-y-2 mt-2">
{fields.map((field, index) => (
<div key={field.id} className="space-y-2 p-3 border rounded">
<FormField
control={form.control}
name={`networks.${index}.Target`}
render={({ field }) => (
<FormItem>
<FormLabel>Network Name</FormLabel>
<FormControl>
<Input {...field} placeholder="my-network" />
</FormControl>
<FormDescription>
The name of the network to attach to
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`networks.${index}.Aliases`}
render={({ field }) => (
<FormItem>
<FormLabel>Aliases (optional)</FormLabel>
<FormControl>
<Input
{...field}
placeholder="alias1, alias2, alias3"
/>
</FormControl>
<FormDescription>
Comma-separated list of network aliases
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>Driver options (optional)</FormLabel>
<FormDescription>
e.g. com.docker.network.driver.mtu,
com.docker.network.driver.host_binding
</FormDescription>
{(
form.watch(`networks.${index}.DriverOptsEntries`) ?? []
).map((_, optIndex) => (
<div
key={optIndex}
className="flex gap-2 items-end flex-wrap"
>
<FormField
control={form.control}
name={`networks.${index}.DriverOptsEntries.${optIndex}.key`}
render={({ field }) => (
<FormItem className="flex-1 min-w-[140px]">
<FormControl>
<Input
{...field}
placeholder="com.docker.network.driver.mtu"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`networks.${index}.DriverOptsEntries.${optIndex}.value`}
render={({ field }) => (
<FormItem className="flex-1 min-w-[100px]">
<FormControl>
<Input {...field} placeholder="1500" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const entries =
form.getValues(
`networks.${index}.DriverOptsEntries`,
) ?? [];
form.setValue(
`networks.${index}.DriverOptsEntries`,
entries.filter((_, i) => i !== optIndex),
);
}}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const entries =
form.getValues(`networks.${index}.DriverOptsEntries`) ??
[];
form.setValue(`networks.${index}.DriverOptsEntries`, [
...entries,
{ key: "", value: "" },
]);
}}
>
Add driver option
</Button>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => remove(index)}
>
Remove Network
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({
Target: "",
Aliases: "",
DriverOptsEntries: [],
})
}
>
Add Network
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({ networks: [] });
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Networks
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,357 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const PreferenceSchema = z.object({
SpreadDescriptor: z.string(),
});
const PlatformSchema = z.object({
Architecture: z.string(),
OS: z.string(),
});
export const placementFormSchema = z.object({
Constraints: z.array(z.string()).optional(),
Preferences: z.array(PreferenceSchema).optional(),
MaxReplicas: z.coerce.number().optional(),
Platforms: z.array(PlatformSchema).optional(),
});
interface PlacementFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(placementFormSchema),
defaultValues: {
Constraints: [],
Preferences: [],
MaxReplicas: undefined,
Platforms: [],
},
});
const constraints = form.watch("Constraints") || [];
const preferences = form.watch("Preferences") || [];
const platforms = form.watch("Platforms") || [];
useEffect(() => {
if (data?.placementSwarm) {
const placement = data.placementSwarm;
form.reset({
Constraints: placement.Constraints || [],
Preferences:
placement.Preferences?.map((p: any) => ({
SpreadDescriptor: p.Spread?.SpreadDescriptor || "",
})) || [],
MaxReplicas: placement.MaxReplicas,
Platforms: placement.Platforms || [],
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof placementFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
(formData.Constraints && formData.Constraints.length > 0) ||
(formData.Preferences && formData.Preferences.length > 0) ||
(formData.Platforms && formData.Platforms.length > 0) ||
formData.MaxReplicas !== undefined;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
placementSwarm: hasAnyValue
? {
...formData,
Preferences: formData.Preferences?.map((p) => ({
Spread: { SpreadDescriptor: p.SpreadDescriptor },
})),
}
: null,
});
toast.success("Placement updated successfully");
refetch();
} catch {
toast.error("Error updating placement");
} finally {
setIsLoading(false);
}
};
const addConstraint = () => {
form.setValue("Constraints", [...constraints, ""]);
};
const updateConstraint = (index: number, value: string) => {
const newConstraints = [...constraints];
newConstraints[index] = value;
form.setValue("Constraints", newConstraints);
};
const removeConstraint = (index: number) => {
form.setValue(
"Constraints",
constraints.filter((_: string, i: number) => i !== index),
);
};
const addPreference = () => {
form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]);
};
const updatePreference = (index: number, value: string) => {
const newPreferences = [...preferences];
if (newPreferences[index]) {
newPreferences[index].SpreadDescriptor = value;
form.setValue("Preferences", newPreferences);
}
};
const removePreference = (index: number) => {
form.setValue(
"Preferences",
preferences.filter((_: any, i: number) => i !== index),
);
};
const addPlatform = () => {
form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]);
};
const updatePlatform = (
index: number,
field: "Architecture" | "OS",
value: string,
) => {
const newPlatforms = [...platforms];
if (newPlatforms[index]) {
newPlatforms[index][field] = value;
form.setValue("Platforms", newPlatforms);
}
};
const removePlatform = (index: number) => {
form.setValue(
"Platforms",
platforms.filter((_: any, i: number) => i !== index),
);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Constraints</FormLabel>
<FormDescription>
Placement constraints (e.g., "node.role==manager")
</FormDescription>
<div className="space-y-2 mt-2">
{constraints.map((constraint: string, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={constraint}
onChange={(e) => updateConstraint(index, e.target.value)}
placeholder="node.role==manager"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeConstraint(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addConstraint}
>
Add Constraint
</Button>
</div>
</div>
<div>
<FormLabel>Preferences</FormLabel>
<FormDescription>
Spread preferences for task distribution (e.g.,
"node.labels.region")
</FormDescription>
<div className="space-y-2 mt-2">
{preferences.map((pref: any, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={pref.SpreadDescriptor}
onChange={(e) => updatePreference(index, e.target.value)}
placeholder="node.labels.region"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removePreference(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPreference}
>
Add Preference
</Button>
</div>
</div>
<FormField
control={form.control}
name="MaxReplicas"
render={({ field }) => (
<FormItem>
<FormLabel>Max Replicas</FormLabel>
<FormDescription>
Maximum number of replicas per node
</FormDescription>
<FormControl>
<Input type="number" placeholder="10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>Platforms</FormLabel>
<FormDescription>
Target platforms for task scheduling
</FormDescription>
<div className="space-y-2 mt-2">
{platforms.map((platform: any, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={platform.Architecture}
onChange={(e) =>
updatePlatform(index, "Architecture", e.target.value)
}
placeholder="amd64"
/>
<Input
value={platform.OS}
onChange={(e) => updatePlatform(index, "OS", e.target.value)}
placeholder="linux"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removePlatform(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPlatform}
>
Add Platform
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Constraints: [],
Preferences: [],
MaxReplicas: undefined,
Platforms: [],
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Placement
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,229 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const restartPolicyFormSchema = z.object({
Condition: z.string().optional(),
Delay: z.coerce.number().optional(),
MaxAttempts: z.coerce.number().optional(),
Window: z.coerce.number().optional(),
});
interface RestartPolicyFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(restartPolicyFormSchema),
defaultValues: {
Condition: undefined,
Delay: undefined,
MaxAttempts: undefined,
Window: undefined,
},
});
useEffect(() => {
if (data?.restartPolicySwarm) {
form.reset({
Condition: data.restartPolicySwarm.Condition,
Delay: data.restartPolicySwarm.Delay,
MaxAttempts: data.restartPolicySwarm.MaxAttempts,
Window: data.restartPolicySwarm.Window,
});
}
}, [data, form]);
const onSubmit = async (
formData: z.infer<typeof restartPolicyFormSchema>,
) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
restartPolicySwarm: hasAnyValue ? formData : null,
});
toast.success("Restart policy updated successfully");
refetch();
} catch {
toast.error("Error updating restart policy");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Condition"
render={({ field }) => (
<FormItem>
<FormLabel>Condition</FormLabel>
<FormDescription>When to restart the container</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select restart condition" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="on-failure">On Failure</SelectItem>
<SelectItem value="any">Any</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>
Wait time between restart attempts
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxAttempts"
render={({ field }) => (
<FormItem>
<FormLabel>Max Attempts</FormLabel>
<FormDescription>
Maximum number of restart attempts
</FormDescription>
<FormControl>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Window"
render={({ field }) => (
<FormItem>
<FormLabel>Window (nanoseconds)</FormLabel>
<FormDescription>
Time window to evaluate restart policy
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Condition: undefined,
Delay: undefined,
MaxAttempts: undefined,
Window: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Restart Policy
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,267 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const rollbackConfigFormSchema = z.object({
Parallelism: z.coerce.number().optional(),
Delay: z.coerce.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.coerce.number().optional(),
MaxFailureRatio: z.coerce.number().optional(),
Order: z.string().optional(),
});
interface RollbackConfigFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(rollbackConfigFormSchema),
defaultValues: {
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
},
});
useEffect(() => {
if (data?.rollbackConfigSwarm) {
form.reset(data.rollbackConfigSwarm);
}
}, [data, form]);
const onSubmit = async (
formData: z.infer<typeof rollbackConfigFormSchema>,
) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
});
toast.success("Rollback config updated successfully");
refetch();
} catch {
toast.error("Error updating rollback config");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Parallelism"
render={({ field }) => (
<FormItem>
<FormLabel>Parallelism</FormLabel>
<FormDescription>
Number of tasks to rollback simultaneously
</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>Delay between task rollbacks</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="FailureAction"
render={({ field }) => (
<FormItem>
<FormLabel>Failure Action</FormLabel>
<FormDescription>Action on rollback failure</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select failure action" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pause">Pause</SelectItem>
<SelectItem value="continue">Continue</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Monitor"
render={({ field }) => (
<FormItem>
<FormLabel>Monitor (nanoseconds)</FormLabel>
<FormDescription>
Duration to monitor for failure after rollback
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxFailureRatio"
render={({ field }) => (
<FormItem>
<FormLabel>Max Failure Ratio</FormLabel>
<FormDescription>
Maximum failure ratio tolerated (0-1)
</FormDescription>
<FormControl>
<Input type="number" step="0.01" placeholder="0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Order"
render={({ field }) => (
<FormItem>
<FormLabel>Order</FormLabel>
<FormDescription>Rollback order strategy</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select order" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="stop-first">Stop First</SelectItem>
<SelectItem value="start-first">Start First</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Rollback Config
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,164 +0,0 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface StopGracePeriodFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
defaultValues: {
value: null as number | null,
},
});
useEffect(() => {
if (hasStopGracePeriodSwarm(data)) {
const value = data.stopGracePeriodSwarm;
const normalizedValue =
value === null || value === undefined ? null : Number(value);
form.reset({
value: normalizedValue,
});
}
}, [data, form]);
const onSubmit = async (formData: any) => {
setIsLoading(true);
try {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
stopGracePeriodSwarm: formData.value,
});
toast.success("Stop grace period updated successfully");
refetch();
} catch {
toast.error("Error updating stop grace period");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
<FormDescription>
Time to wait before forcefully killing the container
<br />
Examples: 30000000000 (30s), 120000000000 (2m)
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="30000000000"
{...field}
value={
field?.value !== null && field?.value !== undefined
? field.value.toString()
: ""
}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : null,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
value: null,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Stop Grace Period
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,274 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const updateConfigFormSchema = z.object({
Parallelism: z.coerce.number().optional(),
Delay: z.coerce.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.coerce.number().optional(),
MaxFailureRatio: z.coerce.number().optional(),
Order: z.string().optional(),
});
interface UpdateConfigFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(updateConfigFormSchema),
defaultValues: {
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
},
});
useEffect(() => {
if (data?.updateConfigSwarm) {
const config = data.updateConfigSwarm;
form.reset({
Parallelism: config.Parallelism,
Delay: config.Delay,
FailureAction: config.FailureAction,
Monitor: config.Monitor,
MaxFailureRatio: config.MaxFailureRatio,
Order: config.Order,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof updateConfigFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
});
toast.success("Update config updated successfully");
refetch();
} catch {
toast.error("Error updating update config");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Parallelism"
render={({ field }) => (
<FormItem>
<FormLabel>Parallelism</FormLabel>
<FormDescription>
Number of tasks to update simultaneously
</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>Delay between task updates</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="FailureAction"
render={({ field }) => (
<FormItem>
<FormLabel>Failure Action</FormLabel>
<FormDescription>Action on update failure</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select failure action" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pause">Pause</SelectItem>
<SelectItem value="continue">Continue</SelectItem>
<SelectItem value="rollback">Rollback</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Monitor"
render={({ field }) => (
<FormItem>
<FormLabel>Monitor (nanoseconds)</FormLabel>
<FormDescription>
Duration to monitor for failure after update
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxFailureRatio"
render={({ field }) => (
<FormItem>
<FormLabel>Max Failure Ratio</FormLabel>
<FormDescription>
Maximum failure ratio tolerated (0-1)
</FormDescription>
<FormControl>
<Input type="number" step="0.01" placeholder="0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Order"
render={({ field }) => (
<FormItem>
<FormLabel>Order</FormLabel>
<FormDescription>Update order strategy</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select order" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="stop-first">Stop First</SelectItem>
<SelectItem value="start-first">Start First</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Update Config
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,31 +0,0 @@
/**
* Filters out undefined, null, and empty string values from form data
* Only returns fields that have actual values
*/
export const filterEmptyValues = (
formData: Record<string, any>,
): Record<string, any> => {
return Object.entries(formData).reduce(
(acc, [key, value]) => {
// Keep arrays even if empty (they might be intentionally cleared)
if (Array.isArray(value)) {
if (value.length > 0) {
acc[key] = value;
}
}
// For other values, filter out undefined, null, and empty strings
else if (value !== undefined && value !== null && value !== "") {
acc[key] = value;
}
return acc;
},
{} as Record<string, any>,
);
};
/**
* Checks if filtered data has any values to save
*/
export const hasValues = (data: Record<string, any>): boolean => {
return Object.keys(data).length > 0;
};

View File

@@ -1,7 +1,6 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Plus, Trash2 } from "lucide-react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -29,13 +28,6 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
args: z
.array(
z.object({
value: z.string().min(1, "Argument cannot be empty"),
}),
)
.optional(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -50,35 +42,27 @@ export const AddCommand = ({ applicationId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isPending } = api.application.update.useMutation();
const { mutateAsync, isLoading } = api.application.update.useMutation();
const form = useForm<AddCommand>({
defaultValues: {
command: "",
args: [],
},
resolver: zodResolver(AddRedirectSchema),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "args",
});
useEffect(() => {
if (data) {
if (data?.command) {
form.reset({
command: data?.command || "",
args: data?.args?.map((arg) => ({ value: arg })) || [],
});
}
}, [data, form]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId,
command: data?.command,
args: data?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Command Updated");
@@ -116,68 +100,16 @@ export const AddCommand = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="/bin/sh" {...field} />
<Input placeholder="Custom command" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Arguments (Args)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ value: "" })}
>
<Plus className="h-4 w-4 mr-1" />
Add Argument
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No arguments added yet. Click "Add Argument" to add one.
</p>
)}
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`args.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex gap-2">
<FormControl>
<Input
placeholder={
index === 0 ? "-c" : "echo Hello World"
}
{...field}
/>
</FormControl>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</div>
<div className="flex justify-end">
<Button isLoading={isPending} type="submit" className="w-fit">
<Button isLoading={isLoading} type="submit" className="w-fit">
Save
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { Code2, Globe2, HardDrive } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -69,11 +69,11 @@ export const ShowImport = ({ composeId }: Props) => {
} | null>(null);
const utils = api.useUtils();
const { mutateAsync: processTemplate, isPending: isLoadingTemplate } =
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
api.compose.processTemplate.useMutation();
const {
mutateAsync: importTemplate,
isPending: isImporting,
isLoading: isImporting,
isSuccess: isImportSuccess,
} = api.compose.import.useMutation();

View File

@@ -1,4 +1,4 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
@@ -35,9 +35,13 @@ import { api } from "@/utils/api";
const AddPortSchema = z.object({
publishedPort: z.number().int().min(1).max(65535),
publishMode: z.enum(["ingress", "host"]),
publishMode: z.enum(["ingress", "host"], {
required_error: "Publish mode is required",
}),
targetPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"]),
protocol: z.enum(["tcp", "udp"], {
required_error: "Protocol is required",
}),
});
type AddPort = z.infer<typeof AddPortSchema>;
@@ -64,7 +68,7 @@ export const HandlePorts = ({
enabled: !!portId,
},
);
const { mutateAsync, isPending, error, isError } = portId
const { mutateAsync, isLoading, error, isError } = portId
? api.port.update.useMutation()
: api.port.create.useMutation();
@@ -266,7 +270,7 @@ export const HandlePorts = ({
<DialogFooter>
<Button
isLoading={isPending}
isLoading={isLoading}
form="hook-form-add-port"
type="submit"
>

View File

@@ -25,7 +25,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
{ enabled: !!applicationId },
);
const { mutateAsync: deletePort, isPending: isRemoving } =
const { mutateAsync: deletePort, isLoading: isRemoving } =
api.port.delete.useMutation();
return (

View File

@@ -1,4 +1,4 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const AddRedirectSchema = z.object({
const AddRedirectchema = z.object({
regex: z.string().min(1, "Regex required"),
permanent: z.boolean().default(false),
replacement: z.string().min(1, "Replacement required"),
});
type AddRedirect = z.infer<typeof AddRedirectSchema>;
type AddRedirect = z.infer<typeof AddRedirectchema>;
// Default presets
const redirectPresets = [
@@ -100,17 +100,17 @@ export const HandleRedirect = ({
const utils = api.useUtils();
const { mutateAsync, isPending, error, isError } = redirectId
const { mutateAsync, isLoading, error, isError } = redirectId
? api.redirects.update.useMutation()
: api.redirects.create.useMutation();
const form = useForm({
const form = useForm<AddRedirect>({
defaultValues: {
permanent: false,
regex: "",
replacement: "",
},
resolver: zodResolver(AddRedirectSchema),
resolver: zodResolver(AddRedirectchema),
});
useEffect(() => {
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
const onDialogToggle = (open: boolean) => {
setIsOpen(open);
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// setPresetSelected("");
// form.reset();
};
@@ -268,7 +268,7 @@ export const HandleRedirect = ({
<DialogFooter>
<Button
isLoading={isPending}
isLoading={isLoading}
form="hook-form-add-redirect"
type="submit"
>

View File

@@ -24,7 +24,7 @@ export const ShowRedirects = ({ applicationId }: Props) => {
{ enabled: !!applicationId },
);
const { mutateAsync: deleteRedirect, isPending: isRemoving } =
const { mutateAsync: deleteRedirect, isLoading: isRemoving } =
api.redirects.delete.useMutation();
const utils = api.useUtils();

View File

@@ -1,4 +1,4 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -46,7 +46,7 @@ export const HandleSecurity = ({
}: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data, refetch } = api.security.one.useQuery(
const { data } = api.security.one.useQuery(
{
securityId: securityId ?? "",
},
@@ -55,7 +55,7 @@ export const HandleSecurity = ({
},
);
const { mutateAsync, isPending, error, isError } = securityId
const { mutateAsync, isLoading, error, isError } = securityId
? api.security.update.useMutation()
: api.security.create.useMutation();
@@ -88,7 +88,6 @@ export const HandleSecurity = ({
await utils.application.readTraefikConfig.invalidate({
applicationId,
});
await refetch();
setIsOpen(false);
})
.catch(() => {
@@ -164,7 +163,7 @@ export const HandleSecurity = ({
<DialogFooter>
<Button
isLoading={isPending}
isLoading={isLoading}
form="hook-form-add-security"
type="submit"
>

Some files were not shown because too many files have changed in this diff Show More