mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-16 13:45:28 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12784ec0ff | |||
| a0e0574bcd | |||
| c453ac3a2f | |||
| f397c1a862 | |||
| 16ac4633f3 | |||
| d7d75f7c5c | |||
| a0e1001808 | |||
| 8236c31fcd | |||
| 037230496c | |||
| 14e059784c | |||
| f34151bc12 | |||
| d00ae31920 | |||
| 95b2712c26 | |||
| 853ca33659 | |||
| a90c2a662f | |||
| 45dcdfc5be | |||
| 29cdc815db | |||
| 9fa98de9c1 | |||
| a4a1d15656 | |||
| 8075a7ce95 | |||
| 326908869d | |||
| 18d07c7254 | |||
| 4cd4b83523 | |||
| f91b2e2ca6 | |||
| e70c880612 | |||
| 0c1dfe978c | |||
| 6dcd0e1859 | |||
| 96778433db | |||
| f25810fa72 | |||
| 341f38bba6 | |||
| f67fab0675 | |||
| 27dab3f4bf | |||
| 5bc53bfbff | |||
| 549b124fcd | |||
| 0c5da0b36f | |||
| ee18724dd7 | |||
| 1ae9b4025c | |||
| 5bc870dc2d | |||
| 299950a323 |
@@ -14,34 +14,34 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get version from package.json
|
||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
|
||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Get latest GitHub tag
|
||||
run: |
|
||||
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
|
||||
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||
echo $LATEST_TAG
|
||||
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
|
||||
echo "$LATEST_TAG"
|
||||
- name: Compare versions
|
||||
run: |
|
||||
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
|
||||
if [ "$VERSION" != "$LATEST_TAG" ]; then
|
||||
VERSION_CHANGED="true"
|
||||
else
|
||||
VERSION_CHANGED="false"
|
||||
fi
|
||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
|
||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
|
||||
echo "Comparing versions:"
|
||||
echo "Current version: ${{ env.VERSION }}"
|
||||
echo "Latest tag: ${{ env.LATEST_TAG }}"
|
||||
echo "Current version: $VERSION"
|
||||
echo "Latest tag: $LATEST_TAG"
|
||||
echo "Version changed: $VERSION_CHANGED"
|
||||
- name: Check if a PR already exists
|
||||
run: |
|
||||
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
|
||||
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
|
||||
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_PAT }}
|
||||
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
git push origin canary
|
||||
|
||||
gh pr create \
|
||||
--title "🚀 Release ${{ env.VERSION }}" \
|
||||
--body '
|
||||
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
|
||||
--title "🚀 Release $VERSION" \
|
||||
--body "
|
||||
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
|
||||
|
||||
### 🔍 Changes Include:
|
||||
- Version bump to ${{ env.VERSION }}
|
||||
- Version bump to $VERSION
|
||||
- All changes from canary branch
|
||||
|
||||
### ✅ Pre-merge Checklist:
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
- [ ] Documentation updated
|
||||
- [ ] Docker images built and tested
|
||||
|
||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
|
||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
|
||||
--base main \
|
||||
--head canary \
|
||||
--label "release" --label "automated pr" || true \
|
||||
|
||||
@@ -5,33 +5,38 @@ on:
|
||||
branches: [main, canary]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-push-cloud-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
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
|
||||
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.cloud
|
||||
@@ -48,26 +53,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
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
|
||||
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.schedule
|
||||
@@ -80,26 +87,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
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
|
||||
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.server
|
||||
|
||||
@@ -8,18 +8,23 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: dokploy/dokploy
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-amd:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -27,15 +32,15 @@ jobs:
|
||||
- name: Set tag and version
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Prepare env file
|
||||
run: |
|
||||
@@ -43,7 +48,7 @@ jobs:
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -53,13 +58,15 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -68,15 +75,15 @@ jobs:
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Prepare env file
|
||||
run: |
|
||||
@@ -84,7 +91,7 @@ jobs:
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -96,20 +103,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
TAG="latest"
|
||||
|
||||
@@ -121,7 +130,7 @@ jobs:
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
${IMAGE_NAME}:${TAG}-arm64
|
||||
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
@@ -138,32 +147,37 @@ jobs:
|
||||
needs: [combine-manifests]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fetch install.sh
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
|
||||
run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
||||
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
||||
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||
mv install-pinned.sh install.sh
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
@@ -179,15 +193,19 @@ jobs:
|
||||
needs: [generate-release]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.22.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: pnpm
|
||||
@@ -202,63 +220,63 @@ jobs:
|
||||
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.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
|
||||
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
|
||||
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.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
echo "✅ MCP repo synced to version $NPM_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.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||
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.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
echo "✅ CLI repo synced to version $NPM_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.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||
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.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
echo "✅ SDK repo synced to version $NPM_VERSION"
|
||||
|
||||
@@ -6,15 +6,20 @@ on:
|
||||
pull_request:
|
||||
branches: [canary]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup biomeJs
|
||||
uses: biomejs/setup-biome@v2
|
||||
uses: biomejs/setup-biome@v2
|
||||
|
||||
- name: Run Biome formatter
|
||||
run: biome format --write
|
||||
|
||||
@@ -16,17 +16,19 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
- name: Cherry-pick fix to main
|
||||
env:
|
||||
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
run: |
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
SHA="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
SHA="$MERGE_SHA"
|
||||
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
||||
git cherry-pick -x -m 1 "$SHA"
|
||||
else
|
||||
|
||||
@@ -7,12 +7,16 @@ concurrency:
|
||||
group: hotfix-to-main
|
||||
cancel-in-progress: false
|
||||
|
||||
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
@@ -7,18 +7,23 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: dokploy/monitoring
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-amd:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -26,17 +31,17 @@ jobs:
|
||||
- name: Set tag
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.monitoring
|
||||
@@ -47,13 +52,15 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -61,17 +68,17 @@ jobs:
|
||||
- name: Set
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.monitoring
|
||||
@@ -84,27 +91,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
${IMAGE_NAME}:${TAG}-arm64
|
||||
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
|
||||
@@ -14,9 +14,11 @@ jobs:
|
||||
matrix:
|
||||
job: [build, test, typecheck]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -12,17 +12,20 @@ on:
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -28,6 +28,9 @@ env:
|
||||
DOKPLOY_SERVICE: dokploy
|
||||
DOKPLOY_PORT: 3000
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
|
||||
@@ -421,8 +424,10 @@ jobs:
|
||||
# ── Job summary ────────────────────────────────────────────────────────
|
||||
- name: Write job summary
|
||||
if: always()
|
||||
env:
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
run: |
|
||||
STATUS="${{ job.status }}"
|
||||
STATUS="$JOB_STATUS"
|
||||
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
||||
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
||||
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
||||
|
||||
+8403
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Compose, ComposeSpecification } from "@dokploy/server";
|
||||
import {
|
||||
addAppNameToPreventCollision,
|
||||
addSuffixToAllProperties,
|
||||
} from "@dokploy/server";
|
||||
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { stringify } from "yaml";
|
||||
import composeSpec from "../../../components/shared/compose-spec.json";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>()),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
const modelCompose = {
|
||||
services: {
|
||||
app: {
|
||||
image: "my-chat-app",
|
||||
models: ["llm"],
|
||||
},
|
||||
worker: {
|
||||
image: "my-worker",
|
||||
models: {
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
llm: {
|
||||
model: "ai/smollm2",
|
||||
context_size: 2048,
|
||||
runtime_flags: ["--verbose"],
|
||||
},
|
||||
},
|
||||
} satisfies ComposeSpecification;
|
||||
|
||||
test("bundled compose schema declares models", () => {
|
||||
expect(composeSpec.properties).toHaveProperty("models");
|
||||
expect(composeSpec.definitions.model.required).toEqual(["model"]);
|
||||
expect(composeSpec.definitions.service.properties).toHaveProperty("models");
|
||||
});
|
||||
|
||||
test("suffixing does not rename model identifiers or drop model config", () => {
|
||||
const updated = addSuffixToAllProperties(
|
||||
structuredClone(modelCompose),
|
||||
"testhash",
|
||||
);
|
||||
|
||||
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
||||
expect(updated.models?.llm?.context_size).toBe(2048);
|
||||
expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]);
|
||||
expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]);
|
||||
expect(updated.services?.["worker-testhash"]?.models).toEqual({
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
});
|
||||
expect(updated.services).not.toHaveProperty("app");
|
||||
expect(updated.models).not.toHaveProperty("llm-testhash");
|
||||
});
|
||||
|
||||
test("isolated deployment preserves model identifiers", () => {
|
||||
const updated = addAppNameToPreventCollision(
|
||||
structuredClone(modelCompose),
|
||||
"chat-app",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
||||
expect(updated.services?.app?.models).toEqual(["llm"]);
|
||||
expect(updated.services?.worker?.models).toEqual({
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("raw remote compose conversion preserves models", async () => {
|
||||
const converted = await addDomainToCompose(
|
||||
{
|
||||
appName: "chat-app",
|
||||
composeFile: stringify(modelCompose, { lineWidth: 1000 }),
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "docker-compose",
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
serverId: "remote-server",
|
||||
sourceType: "raw",
|
||||
suffix: "",
|
||||
} as unknown as Compose,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||
expect(converted?.models?.llm?.model).toBe("ai/smollm2");
|
||||
expect(converted?.services?.app?.models).toEqual(["llm"]);
|
||||
expect(converted?.services?.worker?.models).toEqual({
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
});
|
||||
});
|
||||
+71
@@ -472,6 +472,77 @@ describe("infisical client", () => {
|
||||
expect(params.get("secretPath")).toBe("/frontend");
|
||||
});
|
||||
|
||||
it("asks for imported secrets and merges them", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/shared",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://imported" });
|
||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||
// snake_case on purpose: `includeImports` is silently ignored and the
|
||||
// response comes back with an empty `imports` array.
|
||||
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
|
||||
});
|
||||
|
||||
it("lets the later import win when two define the same key", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/base",
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
|
||||
},
|
||||
{
|
||||
secretPath: "/override",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://override" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
// Infisical documents this order: "If two imports carry a secret with the
|
||||
// same name, the value from the bottom-most import wins." The response
|
||||
// lists imports in that order, so assigning them in sequence matches it.
|
||||
expect(result).toEqual({ DB_URL: "postgres://override" });
|
||||
});
|
||||
|
||||
it("lets a folder's own secret win over an imported one of the same name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/shared",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://local" });
|
||||
});
|
||||
|
||||
it("throws a clear error for a missing secret", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import {
|
||||
buildSandboxContainerOptions,
|
||||
getSandboxNetworkName,
|
||||
parseSandboxEnvVars,
|
||||
parseSandboxUser,
|
||||
SANDBOX_INTERNET_NETWORK,
|
||||
SANDBOX_ISOLATED_NETWORK,
|
||||
} from "@dokploy/server/utils/sandbox/container";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const spec = {
|
||||
sandboxId: "sb_1",
|
||||
projectId: "proj_1",
|
||||
environmentId: "env_1",
|
||||
image: "python:3.12-slim",
|
||||
cpu: 1.5,
|
||||
memoryMb: 512,
|
||||
pidsLimit: 256,
|
||||
networkMode: "isolated" as const,
|
||||
envVars: "FOO=bar\n# comment\n\nBAZ=qux=1\ninvalid",
|
||||
workdir: "/home/user",
|
||||
user: "1000:1000",
|
||||
};
|
||||
|
||||
describe("buildSandboxContainerOptions", () => {
|
||||
it("applies cpu, memory and pids limits", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.HostConfig?.NanoCpus).toBe(1_500_000_000);
|
||||
expect(options.HostConfig?.Memory).toBe(512 * 1024 * 1024);
|
||||
expect(options.HostConfig?.MemorySwap).toBe(512 * 1024 * 1024);
|
||||
expect(options.HostConfig?.PidsLimit).toBe(256);
|
||||
});
|
||||
|
||||
it("hardens the container", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.HostConfig?.CapDrop).toEqual(["ALL"]);
|
||||
expect(options.HostConfig?.SecurityOpt).toEqual(["no-new-privileges"]);
|
||||
expect(options.HostConfig?.RestartPolicy).toEqual({ Name: "no" });
|
||||
expect(options.HostConfig?.LogConfig?.Config?.["max-size"]).toBe("10m");
|
||||
expect(options.User).toBe("1000:1000");
|
||||
});
|
||||
|
||||
it("keeps the container alive with a portable command under an init", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.Entrypoint).toEqual([""]);
|
||||
expect(options.Cmd).toEqual(["tail", "-f", "/dev/null"]);
|
||||
expect(options.HostConfig?.Init).toBe(true);
|
||||
expect(options.WorkingDir).toBe("/home/user");
|
||||
});
|
||||
|
||||
it("sets the labels used by the reaper and reconcile", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.name).toBe("dokploy-sandbox-sb_1");
|
||||
expect(options.Labels).toEqual({
|
||||
"dokploy.sandbox": "true",
|
||||
"dokploy.sandboxId": "sb_1",
|
||||
"dokploy.projectId": "proj_1",
|
||||
"dokploy.environmentId": "env_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("selects the isolated or internet network and never dokploy-network", () => {
|
||||
expect(buildSandboxContainerOptions(spec).HostConfig?.NetworkMode).toBe(
|
||||
SANDBOX_ISOLATED_NETWORK,
|
||||
);
|
||||
expect(
|
||||
buildSandboxContainerOptions({ ...spec, networkMode: "internet" })
|
||||
.HostConfig?.NetworkMode,
|
||||
).toBe(SANDBOX_INTERNET_NETWORK);
|
||||
expect(getSandboxNetworkName("isolated")).not.toBe("dokploy-network");
|
||||
expect(getSandboxNetworkName("internet")).not.toBe("dokploy-network");
|
||||
});
|
||||
|
||||
it("passes env vars and HOME, skipping comments and malformed lines", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.Env).toEqual(["HOME=/home/user", "FOO=bar", "BAZ=qux=1"]);
|
||||
});
|
||||
|
||||
it("omits User when the image default should be used", () => {
|
||||
const options = buildSandboxContainerOptions({ ...spec, user: null });
|
||||
expect(options).not.toHaveProperty("User");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxEnvVars", () => {
|
||||
it("returns an empty list for empty input", () => {
|
||||
expect(parseSandboxEnvVars(null)).toEqual([]);
|
||||
expect(parseSandboxEnvVars("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxUser", () => {
|
||||
it("parses uid:gid", () => {
|
||||
expect(parseSandboxUser("1000:1000")).toEqual({ uid: 1000, gid: 1000 });
|
||||
expect(parseSandboxUser("1001")).toEqual({ uid: 1001, gid: 1001 });
|
||||
});
|
||||
|
||||
it("rejects named users", () => {
|
||||
expect(parseSandboxUser("node")).toBeNull();
|
||||
expect(parseSandboxUser(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import {
|
||||
buildSandboxKillCommand,
|
||||
runSandboxExec,
|
||||
SANDBOX_EXEC_MARKER_ENV,
|
||||
SANDBOX_EXEC_TIMEOUT_EXIT_CODE,
|
||||
toSandboxEnvArray,
|
||||
} from "@dokploy/server/utils/sandbox/exec";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Docker multiplexes stdout/stderr on a non-tty exec as 8-byte frames:
|
||||
// [stream type, 0, 0, 0, size (BE uint32)] followed by the payload.
|
||||
const frame = (type: 1 | 2, text: string) => {
|
||||
const payload = Buffer.from(text);
|
||||
const header = Buffer.alloc(8);
|
||||
header[0] = type;
|
||||
header.writeUInt32BE(payload.length, 4);
|
||||
return Buffer.concat([header, payload]);
|
||||
};
|
||||
|
||||
// Mirrors docker-modem's demuxStream so the runner is exercised end to end.
|
||||
const demuxStream = (
|
||||
stream: NodeJS.ReadableStream,
|
||||
stdout: NodeJS.WritableStream,
|
||||
stderr: NodeJS.WritableStream,
|
||||
) => {
|
||||
let buffer = Buffer.alloc(0);
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
while (buffer.length >= 8) {
|
||||
const size = buffer.readUInt32BE(4);
|
||||
if (buffer.length < 8 + size) break;
|
||||
const payload = buffer.subarray(8, 8 + size);
|
||||
(buffer[0] === 2 ? stderr : stdout).write(payload);
|
||||
buffer = buffer.subarray(8 + size);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createDocker = (opts: {
|
||||
frames: Buffer[];
|
||||
exitCode: number | null;
|
||||
endAfterMs?: number;
|
||||
dieOnKill?: boolean;
|
||||
}) => {
|
||||
const stream = new PassThrough();
|
||||
const execCreate = vi.fn();
|
||||
const execStart = vi.fn();
|
||||
const kill = vi.fn(async () => {
|
||||
stream.end();
|
||||
});
|
||||
const start = vi.fn(async () => undefined);
|
||||
const killExecStart = vi.fn(async () => {
|
||||
if (opts.dieOnKill) stream.end();
|
||||
});
|
||||
const inspect = vi.fn(async () => ({ ExitCode: opts.exitCode }));
|
||||
|
||||
const exec = vi.fn(async (options: { Cmd: string[] }) => {
|
||||
execCreate(options);
|
||||
if (options.Cmd[2]?.startsWith("if grep -qzF x /dev/null")) {
|
||||
return { start: killExecStart, inspect };
|
||||
}
|
||||
return {
|
||||
start: vi.fn(async (startOptions: unknown) => {
|
||||
execStart(startOptions);
|
||||
setTimeout(() => {
|
||||
for (const chunk of opts.frames) stream.write(chunk);
|
||||
if (opts.endAfterMs === undefined) stream.end();
|
||||
else if (opts.endAfterMs > 0)
|
||||
setTimeout(() => stream.end(), opts.endAfterMs);
|
||||
}, 5);
|
||||
return stream;
|
||||
}),
|
||||
inspect,
|
||||
};
|
||||
});
|
||||
|
||||
const docker = {
|
||||
getContainer: vi.fn(() => ({ exec, kill, start })),
|
||||
modem: { demuxStream },
|
||||
};
|
||||
return { docker, execCreate, execStart, kill, start, killExecStart, inspect };
|
||||
};
|
||||
|
||||
describe("runSandboxExec", () => {
|
||||
it("demuxes stdout/stderr and reports the exit code", async () => {
|
||||
const { docker, execCreate, execStart } = createDocker({
|
||||
frames: [frame(1, "hello "), frame(1, "world\n"), frame(2, "oops\n")],
|
||||
exitCode: 3,
|
||||
});
|
||||
const stdoutChunks: string[] = [];
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "echo hello world; echo oops >&2; exit 3",
|
||||
cwd: "/home/user",
|
||||
env: { FOO: "bar" },
|
||||
timeoutMs: 5000,
|
||||
onStdout: (chunk) => stdoutChunks.push(chunk),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stdout: "hello world\n",
|
||||
stderr: "oops\n",
|
||||
exitCode: 3,
|
||||
timedOut: false,
|
||||
truncated: false,
|
||||
containerKilled: false,
|
||||
});
|
||||
expect(stdoutChunks.join("")).toBe("hello world\n");
|
||||
|
||||
const options = execCreate.mock.calls[0]?.[0];
|
||||
expect(options.Cmd).toEqual([
|
||||
"sh",
|
||||
"-c",
|
||||
"echo hello world; echo oops >&2; exit 3",
|
||||
]);
|
||||
expect(options.WorkingDir).toBe("/home/user");
|
||||
expect(options.Tty).toBe(false);
|
||||
expect(options.Env).toContain("FOO=bar");
|
||||
expect(
|
||||
options.Env.some((e: string) =>
|
||||
e.startsWith(`${SANDBOX_EXEC_MARKER_ENV}=`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(execStart).toHaveBeenCalledWith({ hijack: true, stdin: false });
|
||||
});
|
||||
|
||||
it("treats a null exit code as failure", async () => {
|
||||
const { docker } = createDocker({ frames: [], exitCode: null });
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "true",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("truncates output beyond maxOutputBytes but keeps streaming", async () => {
|
||||
const { docker } = createDocker({
|
||||
frames: [frame(1, "a".repeat(100)), frame(1, "b".repeat(100))],
|
||||
exitCode: 0,
|
||||
});
|
||||
const streamed: string[] = [];
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "yes",
|
||||
timeoutMs: 5000,
|
||||
maxOutputBytes: 150,
|
||||
onStdout: (chunk) => streamed.push(chunk),
|
||||
});
|
||||
expect(result.stdout).toHaveLength(150);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(streamed.join("")).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("kills the process on timeout and returns exit code 124", async () => {
|
||||
const { docker, killExecStart, kill } = createDocker({
|
||||
frames: [frame(1, "partial")],
|
||||
exitCode: 0,
|
||||
endAfterMs: 0,
|
||||
dieOnKill: true,
|
||||
});
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "sleep 100",
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
|
||||
expect(result.stdout).toBe("partial");
|
||||
expect(killExecStart).toHaveBeenCalledWith({ Detach: true });
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(result.containerKilled).toBe(false);
|
||||
expect(result.containerRestarted).toBe(false);
|
||||
});
|
||||
|
||||
it("kills and restarts the container when the process survives the marker kill", async () => {
|
||||
const { docker, kill, start } = createDocker({
|
||||
frames: [],
|
||||
exitCode: 0,
|
||||
endAfterMs: 0,
|
||||
dieOnKill: false,
|
||||
});
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "sleep 100",
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
|
||||
expect(kill).toHaveBeenCalledWith({ signal: "SIGKILL" });
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(result.containerKilled).toBe(true);
|
||||
expect(result.containerRestarted).toBe(true);
|
||||
}, 10_000);
|
||||
|
||||
it("reports a failed restart so the sandbox can be marked as error", async () => {
|
||||
const { docker, start } = createDocker({
|
||||
frames: [],
|
||||
exitCode: 0,
|
||||
endAfterMs: 0,
|
||||
dieOnKill: false,
|
||||
});
|
||||
start.mockRejectedValueOnce(new Error("no such container"));
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "sleep 100",
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(result.containerKilled).toBe(true);
|
||||
expect(result.containerRestarted).toBe(false);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe("buildSandboxKillCommand", () => {
|
||||
it("matches the marker as a fixed string and never fails the shell", () => {
|
||||
const cmd = buildSandboxKillCommand("abc-123");
|
||||
expect(cmd).toContain(`grep -qzxF ${quote(["DOKPLOY_EXEC_ID=abc-123"])}`);
|
||||
expect(cmd).toContain(
|
||||
`tr '\\0' '\\n' < "$f" 2>/dev/null | grep -qxF ${quote(["DOKPLOY_EXEC_ID=abc-123"])}`,
|
||||
);
|
||||
expect(parse(cmd)).toContain("DOKPLOY_EXEC_ID=abc-123");
|
||||
expect(cmd).toContain("kill -9");
|
||||
expect(cmd.endsWith("; true")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSandboxEnvArray", () => {
|
||||
it("converts records and passes arrays through", () => {
|
||||
expect(toSandboxEnvArray({ A: "1", B: "x=y" })).toEqual(["A=1", "B=x=y"]);
|
||||
expect(toSandboxEnvArray(["C=3"])).toEqual(["C=3"]);
|
||||
expect(toSandboxEnvArray(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventEmitter sanity", () => {
|
||||
it("keeps the fake stream API compatible with what the runner uses", () => {
|
||||
expect(new PassThrough()).toBeInstanceOf(EventEmitter);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import {
|
||||
buildSandboxFindCommand,
|
||||
buildSandboxLsCommand,
|
||||
parseSandboxFindOutput,
|
||||
parseSandboxLsOutput,
|
||||
} from "@dokploy/server/utils/sandbox/files";
|
||||
import { parse } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("list files commands", () => {
|
||||
it("quotes the path against shell injection", () => {
|
||||
expect(buildSandboxFindCommand("/tmp/a b; rm -rf /")).toContain(
|
||||
"'/tmp/a b; rm -rf /'",
|
||||
);
|
||||
expect(parse(buildSandboxLsCommand("/tmp/$(id)"))).toEqual([
|
||||
"ls",
|
||||
"-1Ap",
|
||||
"/tmp/$(id)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxFindOutput", () => {
|
||||
it("parses type, size, mtime, mode and name, directories first", () => {
|
||||
const out = [
|
||||
"f\t12\t1700000000.5000\t644\tmain.py",
|
||||
"d\t4096\t1700000100.0000\t755\tsrc",
|
||||
"l\t7\t1700000200.0000\t777\tlink",
|
||||
"f\t0\t1700000300.0000\t600\tname with\tspaces",
|
||||
].join("\n");
|
||||
const entries = parseSandboxFindOutput(out);
|
||||
expect(entries.map((e) => e.name)).toEqual([
|
||||
"src",
|
||||
"link",
|
||||
"main.py",
|
||||
"name with\tspaces",
|
||||
]);
|
||||
expect(entries[0]).toEqual({
|
||||
name: "src",
|
||||
type: "directory",
|
||||
size: 4096,
|
||||
mode: "755",
|
||||
modifiedAt: new Date(1700000100 * 1000).toISOString(),
|
||||
});
|
||||
expect(entries[2]).toMatchObject({
|
||||
type: "file",
|
||||
size: 12,
|
||||
modifiedAt: new Date(1700000000500).toISOString(),
|
||||
});
|
||||
expect(entries[1]?.type).toBe("symlink");
|
||||
});
|
||||
|
||||
it("returns an empty list for an empty directory", () => {
|
||||
expect(parseSandboxFindOutput("")).toEqual([]);
|
||||
expect(parseSandboxFindOutput("\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxLsOutput", () => {
|
||||
it("marks trailing-slash entries as directories", () => {
|
||||
const entries = parseSandboxLsOutput("b.txt\na/\n.hidden\n");
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
name: "a",
|
||||
type: "directory",
|
||||
size: null,
|
||||
mode: null,
|
||||
modifiedAt: null,
|
||||
},
|
||||
{
|
||||
name: ".hidden",
|
||||
type: "file",
|
||||
size: null,
|
||||
mode: null,
|
||||
modifiedAt: null,
|
||||
},
|
||||
{ name: "b.txt", type: "file", size: null, mode: null, modifiedAt: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
ensureSandboxNetwork,
|
||||
SANDBOX_NETWORK_OPTIONS,
|
||||
} from "@dokploy/server/services/sandbox";
|
||||
|
||||
const notFound = () =>
|
||||
Object.assign(new Error("no such network"), { statusCode: 404 });
|
||||
|
||||
const createDocker = (inspect: () => Promise<unknown>) => {
|
||||
const remove = vi.fn(async () => undefined);
|
||||
const createNetwork = vi.fn(async () => undefined);
|
||||
const getNetwork = vi.fn(() => ({ inspect, remove }));
|
||||
return {
|
||||
docker: { getNetwork, createNetwork },
|
||||
remove,
|
||||
createNetwork,
|
||||
getNetwork,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("ensureSandboxNetwork", () => {
|
||||
it("creates an internal bridge without inter-container traffic for isolated mode", async () => {
|
||||
const { docker, createNetwork } = createDocker(async () => {
|
||||
throw notFound();
|
||||
});
|
||||
const name = await ensureSandboxNetwork(docker as never, "isolated");
|
||||
expect(name).toBe("dokploy-sandboxes-isolated");
|
||||
expect(createNetwork).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Name: "dokploy-sandboxes-isolated",
|
||||
Driver: "bridge",
|
||||
Internal: true,
|
||||
Options: SANDBOX_NETWORK_OPTIONS,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
SANDBOX_NETWORK_OPTIONS["com.docker.network.bridge.enable_icc"],
|
||||
).toBe("false");
|
||||
});
|
||||
|
||||
it("creates a non-internal bridge for internet mode", async () => {
|
||||
const { docker, createNetwork } = createDocker(async () => {
|
||||
throw notFound();
|
||||
});
|
||||
await ensureSandboxNetwork(docker as never, "internet");
|
||||
expect(createNetwork).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Name: "dokploy-sandboxes",
|
||||
Internal: false,
|
||||
Options: SANDBOX_NETWORK_OPTIONS,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses a network that already blocks inter-container traffic", async () => {
|
||||
const { docker, createNetwork, remove } = createDocker(async () => ({
|
||||
Options: SANDBOX_NETWORK_OPTIONS,
|
||||
Containers: {},
|
||||
}));
|
||||
await ensureSandboxNetwork(docker as never, "isolated");
|
||||
expect(createNetwork).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recreates an unused legacy network that allows inter-container traffic", async () => {
|
||||
const { docker, createNetwork, remove } = createDocker(async () => ({
|
||||
Options: {},
|
||||
Containers: {},
|
||||
}));
|
||||
await ensureSandboxNetwork(docker as never, "isolated");
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
expect(createNetwork).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a legacy network that still has containers attached and warns", async () => {
|
||||
const { docker, createNetwork, remove } = createDocker(async () => ({
|
||||
Options: {},
|
||||
Containers: { abc: { Name: "dokploy-sandbox-x" } },
|
||||
}));
|
||||
await ensureSandboxNetwork(docker as never, "isolated");
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
expect(createNetwork).not.toHaveBeenCalled();
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tolerates a concurrent create of the same network", async () => {
|
||||
const { docker, createNetwork } = createDocker(async () => {
|
||||
throw notFound();
|
||||
});
|
||||
createNetwork.mockRejectedValueOnce(
|
||||
Object.assign(new Error("exists"), { statusCode: 409 }),
|
||||
);
|
||||
await expect(
|
||||
ensureSandboxNetwork(docker as never, "isolated"),
|
||||
).resolves.toBe("dokploy-sandboxes-isolated");
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: { query: { sandboxes: { findMany } } },
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/sandbox", () => ({
|
||||
killSandbox: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
findExpiredSandboxes,
|
||||
planSandboxReconcile,
|
||||
reapExpiredSandboxes,
|
||||
} from "@dokploy/server/utils/sandbox/reaper";
|
||||
|
||||
const now = new Date("2026-09-10T12:00:00Z");
|
||||
const past = new Date(now.getTime() - 1000);
|
||||
const future = new Date(now.getTime() + 60_000);
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("findExpiredSandboxes", () => {
|
||||
it("returns only running sandboxes whose expiresAt has passed", () => {
|
||||
const list = [
|
||||
{ sandboxId: "expired", status: "running", expiresAt: past },
|
||||
{ sandboxId: "alive", status: "running", expiresAt: future },
|
||||
{ sandboxId: "no-expiry", status: "running", expiresAt: null },
|
||||
{ sandboxId: "killed", status: "killed", expiresAt: past },
|
||||
{ sandboxId: "error", status: "error", expiresAt: past },
|
||||
];
|
||||
expect(findExpiredSandboxes(list, now).map((s) => s.sandboxId)).toEqual([
|
||||
"expired",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reapExpiredSandboxes", () => {
|
||||
it("kills expired sandboxes and leaves the rest untouched", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ sandboxId: "expired", status: "running", expiresAt: past },
|
||||
{ sandboxId: "alive", status: "running", expiresAt: future },
|
||||
]);
|
||||
const kill = vi.fn(async () => undefined);
|
||||
const killed = await reapExpiredSandboxes(now, kill);
|
||||
expect(killed).toEqual(["expired"]);
|
||||
expect(kill).toHaveBeenCalledTimes(1);
|
||||
expect(kill).toHaveBeenCalledWith("expired");
|
||||
});
|
||||
|
||||
it("continues when one kill fails", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ sandboxId: "a", status: "running", expiresAt: past },
|
||||
{ sandboxId: "b", status: "running", expiresAt: past },
|
||||
]);
|
||||
const kill = vi.fn(async (id: string) => {
|
||||
if (id === "a") throw new Error("docker down");
|
||||
});
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const killed = await reapExpiredSandboxes(now, kill);
|
||||
spy.mockRestore();
|
||||
expect(killed).toEqual(["b"]);
|
||||
expect(kill).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planSandboxReconcile", () => {
|
||||
const container = (id: string, sandboxId: string, state = "running") => ({
|
||||
Id: id,
|
||||
State: state,
|
||||
Labels: { "dokploy.sandbox": "true", "dokploy.sandboxId": sandboxId },
|
||||
});
|
||||
|
||||
it("removes containers without a running row and errors rows without a container", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[
|
||||
{ sandboxId: "ok", containerId: "c-ok", status: "running" },
|
||||
{ sandboxId: "gone", containerId: "c-gone", status: "running" },
|
||||
{ sandboxId: "never-started", containerId: null, status: "running" },
|
||||
],
|
||||
[
|
||||
container("c-ok", "ok"),
|
||||
container("c-orphan", "orphan"),
|
||||
container("c-exited", "ok-old", "exited"),
|
||||
],
|
||||
);
|
||||
expect(plan.removeContainers).toEqual(["c-orphan", "c-exited"]);
|
||||
expect(plan.markError).toEqual(["gone", "never-started"]);
|
||||
});
|
||||
|
||||
it("removes a stopped container even when its row is running, and errors the row", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[{ sandboxId: "sb", containerId: "c1", status: "running" }],
|
||||
[container("c1", "sb", "exited")],
|
||||
);
|
||||
expect(plan.removeContainers).toEqual(["c1"]);
|
||||
expect(plan.markError).toEqual(["sb"]);
|
||||
});
|
||||
|
||||
it("leaves containers of sandboxes that are still being created alone", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[{ sandboxId: "new", containerId: null, status: "creating" }],
|
||||
[container("c-new", "new")],
|
||||
);
|
||||
expect(plan).toEqual({ removeContainers: [], markError: [] });
|
||||
});
|
||||
|
||||
it("does nothing when everything matches", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[{ sandboxId: "sb", containerId: "c1", status: "running" }],
|
||||
[container("c1", "sb")],
|
||||
);
|
||||
expect(plan).toEqual({ removeContainers: [], markError: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
buildSandboxDirectoryTar,
|
||||
buildSandboxFileTar,
|
||||
extractSandboxFile,
|
||||
SandboxFileTooLargeError,
|
||||
SandboxNotRegularFileError,
|
||||
} from "@dokploy/server/utils/sandbox/tar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type Entry = {
|
||||
name: string;
|
||||
type: string;
|
||||
mode: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
size: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
"0": "file",
|
||||
"\0": "file",
|
||||
"5": "directory",
|
||||
};
|
||||
|
||||
// Minimal ustar reader: 512-byte header blocks with octal numeric fields.
|
||||
const readTar = (buffer: Buffer): Entry[] => {
|
||||
const entries: Entry[] = [];
|
||||
let offset = 0;
|
||||
const field = (start: number, length: number) =>
|
||||
buffer
|
||||
.subarray(offset + start, offset + start + length)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/s, "");
|
||||
const octal = (start: number, length: number) =>
|
||||
Number.parseInt(field(start, length).trim() || "0", 8);
|
||||
while (offset + 512 <= buffer.length) {
|
||||
const name = field(0, 100);
|
||||
if (!name) break;
|
||||
const size = octal(124, 12);
|
||||
const typeflag = buffer[offset + 156] ?? 0;
|
||||
entries.push({
|
||||
name,
|
||||
type: TYPES[String.fromCharCode(typeflag)] ?? "other",
|
||||
mode: octal(100, 8),
|
||||
uid: octal(108, 8),
|
||||
gid: octal(116, 8),
|
||||
size,
|
||||
content: buffer
|
||||
.subarray(offset + 512, offset + 512 + size)
|
||||
.toString("utf8"),
|
||||
});
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
describe("buildSandboxFileTar", () => {
|
||||
it("packs a single file with the requested ownership and mode", async () => {
|
||||
const tar = await buildSandboxFileTar({
|
||||
name: "main.py",
|
||||
content: Buffer.from("print('hi')\n"),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
mode: 0o600,
|
||||
});
|
||||
const entries = await readTar(tar);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: "main.py",
|
||||
type: "file",
|
||||
mode: 0o600,
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
size: 12,
|
||||
content: "print('hi')\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to root ownership and 0644", async () => {
|
||||
const entries = await readTar(
|
||||
await buildSandboxFileTar({ name: "a.txt", content: Buffer.alloc(0) }),
|
||||
);
|
||||
expect(entries[0]).toMatchObject({ mode: 0o644, uid: 0, gid: 0, size: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSandboxDirectoryTar", () => {
|
||||
it("packs a directory entry with a trailing slash", async () => {
|
||||
const entries = await readTar(
|
||||
await buildSandboxDirectoryTar({ name: "user", uid: 1000, gid: 1000 }),
|
||||
);
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: "user/",
|
||||
type: "directory",
|
||||
mode: 0o755,
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSandboxFile", () => {
|
||||
it("extracts the first regular file", async () => {
|
||||
const tar = await buildSandboxFileTar({
|
||||
name: "out.txt",
|
||||
content: Buffer.from("hello"),
|
||||
});
|
||||
const file = await extractSandboxFile(Readable.from(tar), 1024);
|
||||
expect(file.name).toBe("out.txt");
|
||||
expect(file.content.toString()).toBe("hello");
|
||||
});
|
||||
|
||||
it("rejects files over the limit", async () => {
|
||||
const tar = await buildSandboxFileTar({
|
||||
name: "big.bin",
|
||||
content: Buffer.alloc(2048, 1),
|
||||
});
|
||||
await expect(
|
||||
extractSandboxFile(Readable.from(tar), 1024),
|
||||
).rejects.toBeInstanceOf(SandboxFileTooLargeError);
|
||||
});
|
||||
|
||||
it("rejects a directory archive even when it contains files", async () => {
|
||||
const dir = await buildSandboxDirectoryTar({ name: "proj" });
|
||||
const file = await buildSandboxFileTar({
|
||||
name: "proj/main.py",
|
||||
content: Buffer.from("print(1)"),
|
||||
});
|
||||
// tar-stream pads each archive with two zero blocks; strip the first
|
||||
// archive's end-of-archive marker so the entries are read as one tar.
|
||||
const combined = Buffer.concat([dir.subarray(0, dir.length - 1024), file]);
|
||||
await expect(
|
||||
extractSandboxFile(Readable.from(combined), 1024),
|
||||
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
|
||||
});
|
||||
|
||||
it("rejects archives without a regular file", async () => {
|
||||
const tar = await buildSandboxDirectoryTar({ name: "dir" });
|
||||
await expect(
|
||||
extractSandboxFile(Readable.from(tar), 1024),
|
||||
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,45 @@ import { api } from "@/utils/api";
|
||||
|
||||
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||
export const RAILPACK_VERSIONS = [
|
||||
"0.39.0",
|
||||
"0.38.0",
|
||||
"0.37.1",
|
||||
"0.37.0",
|
||||
"0.36.4",
|
||||
"0.36.3",
|
||||
"0.36.2",
|
||||
"0.36.1",
|
||||
"0.36.0",
|
||||
"0.35.0",
|
||||
"0.34.0",
|
||||
"0.33.0",
|
||||
"0.32.0",
|
||||
"0.31.2",
|
||||
"0.31.1",
|
||||
"0.31.0",
|
||||
"0.30.1",
|
||||
"0.30.0",
|
||||
"0.29.0",
|
||||
"0.28.0",
|
||||
"0.27.2",
|
||||
"0.27.1",
|
||||
"0.27.0",
|
||||
"0.26.1",
|
||||
"0.26.0",
|
||||
"0.25.0",
|
||||
"0.24.0",
|
||||
"0.23.0",
|
||||
"0.22.2",
|
||||
"0.22.1",
|
||||
"0.22.0",
|
||||
"0.21.0",
|
||||
"0.20.0",
|
||||
"0.19.0",
|
||||
"0.18.0",
|
||||
"0.17.2",
|
||||
"0.17.1",
|
||||
"0.17.0",
|
||||
"0.16.0",
|
||||
"0.15.4",
|
||||
"0.15.3",
|
||||
"0.15.2",
|
||||
@@ -45,12 +84,22 @@ export const RAILPACK_VERSIONS = [
|
||||
"0.9.1",
|
||||
"0.9.0",
|
||||
"0.8.0",
|
||||
"0.7.2",
|
||||
"0.7.1",
|
||||
"0.7.0",
|
||||
"0.6.1",
|
||||
"0.6.0",
|
||||
"0.5.1",
|
||||
"0.5.0",
|
||||
"0.4.0",
|
||||
"0.3.0",
|
||||
"0.2.3",
|
||||
"0.2.2",
|
||||
"0.2.1",
|
||||
"0.2.0",
|
||||
"0.1.2",
|
||||
"0.1.1",
|
||||
"0.1.0",
|
||||
] as const;
|
||||
|
||||
export enum BuildType {
|
||||
|
||||
@@ -6,28 +6,41 @@ import { api } from "@/utils/api";
|
||||
export const TrialBanner = () => {
|
||||
const router = useRouter();
|
||||
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||
const { mutateAsync: createCustomerPortalSession, isPending } =
|
||||
api.stripe.createCustomerPortalSession.useMutation();
|
||||
|
||||
if (!billingStatus?.isOnTrial) {
|
||||
const handleAddPaymentMethod = async () => {
|
||||
const session = await createCustomerPortalSession().catch(() => null);
|
||||
if (session?.url) {
|
||||
window.open(session.url);
|
||||
return;
|
||||
}
|
||||
router.push("/dashboard/settings/billing");
|
||||
};
|
||||
|
||||
if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
|
||||
const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Rocket className="h-4 w-4 text-primary shrink-0" />
|
||||
{daysRemaining > 0
|
||||
? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.`
|
||||
: "Your free trial ends today."}
|
||||
? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.`
|
||||
: "Your free trial ends today. Add a payment method to keep your servers."}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7"
|
||||
onClick={() => router.push("/dashboard/settings/billing")}
|
||||
isLoading={isPending}
|
||||
onClick={handleAddPaymentMethod}
|
||||
>
|
||||
Upgrade now
|
||||
Add payment method
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -278,8 +278,9 @@ export const OnboardingWizard = ({ onClose }: Props) => {
|
||||
<DialogTitle>Skip onboarding?</DialogTitle>
|
||||
<DialogDescription>
|
||||
If this is your first time using Dokploy, we recommend going
|
||||
through these steps — it only takes a couple of minutes and gives
|
||||
you a feel for how projects, servers and deployments fit together.
|
||||
through these steps – it only takes a couple of minutes and gives
|
||||
you a feel for how projects, servers, and deployments fit
|
||||
together.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -15,13 +15,50 @@ const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
|
||||
type Tier = "hobby" | "startup";
|
||||
|
||||
const PLANS: {
|
||||
tier: Tier;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
features: string[];
|
||||
recommended?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
tier: "hobby",
|
||||
name: "Hobby",
|
||||
description: "For individual developers",
|
||||
price: calculatePriceHobby(1, false),
|
||||
features: [
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"2 environments",
|
||||
"Community support",
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: "startup",
|
||||
name: "Startup",
|
||||
description: "For small to mid-size teams",
|
||||
price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false),
|
||||
recommended: true,
|
||||
features: [
|
||||
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
||||
"Unlimited users & environments",
|
||||
"Basic RBAC + 2FA",
|
||||
"Email & chat support",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export const PlanStep = ({ onNext }: Props) => {
|
||||
const [loadingTier, setLoadingTier] = useState<
|
||||
"hobby" | "startup" | "trial" | null
|
||||
const [loading, setLoading] = useState<
|
||||
`${"trial" | "checkout"}:${Tier}` | null
|
||||
>(null);
|
||||
const { data } = api.stripe.getProducts.useQuery();
|
||||
const { mutateAsync: createCheckoutSession } =
|
||||
@@ -30,12 +67,12 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
api.stripe.startFreeTrial.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleCheckout = async (tier: "hobby" | "startup") => {
|
||||
const handleCheckout = async (tier: Tier) => {
|
||||
if (!data) return;
|
||||
const productId =
|
||||
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
|
||||
if (!productId) return;
|
||||
setLoadingTier(tier);
|
||||
setLoading(`checkout:${tier}`);
|
||||
try {
|
||||
const stripe = await stripePromise;
|
||||
const session = await createCheckoutSession({
|
||||
@@ -47,14 +84,14 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
await stripe?.redirectToCheckout({ sessionId: session.sessionId });
|
||||
} catch {
|
||||
toast.error("Error starting checkout");
|
||||
setLoadingTier(null);
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrial = async () => {
|
||||
setLoadingTier("trial");
|
||||
const handleTrial = async (tier: Tier) => {
|
||||
setLoading(`trial:${tier}`);
|
||||
try {
|
||||
await startFreeTrial();
|
||||
await startFreeTrial({ tier });
|
||||
await utils.project.onboardingStatus.invalidate();
|
||||
toast.success("Your 7-day trial has started");
|
||||
onNext();
|
||||
@@ -63,7 +100,7 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
);
|
||||
} finally {
|
||||
setLoadingTier(null);
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,134 +116,72 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
Start free, upgrade when ready.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
No credit card for the trial — add one only if you decide to stay.
|
||||
Try any plan free for 7 days. No credit card required — add one only
|
||||
if you decide to stay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col rounded-2xl bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 overflow-hidden">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-6 p-7">
|
||||
<div>
|
||||
<span className="inline-flex items-center rounded-full bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1">
|
||||
Recommended
|
||||
</span>
|
||||
<p className={`${displayFont.className} text-2xl mt-3`}>
|
||||
7-day free trial
|
||||
</p>
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-600 mt-1 max-w-xs">
|
||||
No card required — cancel anytime.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"Community support",
|
||||
].map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-zinc-300 dark:text-zinc-700"
|
||||
>
|
||||
<CheckIcon className="size-3.5 text-zinc-500 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-white text-zinc-950 hover:bg-zinc-200 dark:bg-zinc-950 dark:text-white dark:hover:bg-zinc-800 w-fit shrink-0 px-6"
|
||||
isLoading={loadingTier === "trial"}
|
||||
disabled={loadingTier !== null}
|
||||
onClick={handleTrial}
|
||||
>
|
||||
Start trial
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||
<div className="flex flex-col justify-between gap-6 bg-background p-7">
|
||||
<div>
|
||||
<p className="font-medium">Hobby</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
For individual developers
|
||||
</p>
|
||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||
${calculatePriceHobby(1, false).toFixed(2)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{" "}
|
||||
/mo
|
||||
</span>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"2 environments",
|
||||
"Community support",
|
||||
].map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
{PLANS.map((plan) => {
|
||||
const productId =
|
||||
plan.tier === "hobby"
|
||||
? data?.hobbyProductId
|
||||
: data?.startupProductId;
|
||||
return (
|
||||
<div
|
||||
key={plan.tier}
|
||||
className="flex flex-col justify-between gap-6 bg-background p-7"
|
||||
>
|
||||
<div>
|
||||
{plan.recommended && (
|
||||
<span className="inline-flex items-center rounded-full bg-primary text-primary-foreground font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1 mb-3">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
<p className="font-medium">{plan.name}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{plan.description}
|
||||
</p>
|
||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||
${plan.price.toFixed(2)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{" "}
|
||||
/mo
|
||||
</span>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{plan.features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
isLoading={loading === `trial:${plan.tier}`}
|
||||
disabled={loading !== null}
|
||||
onClick={() => handleTrial(plan.tier)}
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={loadingTier === "hobby"}
|
||||
disabled={loadingTier !== null || !data?.hobbyProductId}
|
||||
onClick={() => handleCheckout("hobby")}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between gap-6 bg-background p-7">
|
||||
<div>
|
||||
<p className="font-medium">Startup</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
For small to mid-size teams
|
||||
</p>
|
||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||
$
|
||||
{calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed(
|
||||
2,
|
||||
)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{" "}
|
||||
/mo
|
||||
</span>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
||||
"Unlimited users & environments",
|
||||
"Basic RBAC + 2FA",
|
||||
"Email & chat support",
|
||||
].map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
Start 7-day free trial
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={loading === `checkout:${plan.tier}`}
|
||||
disabled={loading !== null || !productId}
|
||||
onClick={() => handleCheckout(plan.tier)}
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={loadingTier === "startup"}
|
||||
disabled={loadingTier !== null || !data?.startupProductId}
|
||||
onClick={() => handleCheckout("startup")}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
</div>
|
||||
Subscribe now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,7 +65,7 @@ export const ProjectStep = ({ onNext, plainTitle }: Props) => {
|
||||
</span>
|
||||
<h1 className={titleClassName}>Create your first project.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Projects group your apps, databases and environments together.
|
||||
Projects group your apps, databases, and environments together.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CopyIcon,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -200,9 +206,19 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
||||
</span>
|
||||
<h1 className={titleClassName}>Connect a server.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Dokploy deploys to servers you own — buy one from any VPS provider
|
||||
Dokploy deploys to servers you own. Buy one from any VPS provider
|
||||
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
|
||||
</p>
|
||||
<div className="flex items-start gap-2.5 rounded-lg border p-3 max-w-md">
|
||||
<InfoIcon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">Requirements</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
To ensure a smooth experience with Dokploy, your server should
|
||||
have at least 1GB of RAM and 30GB of disk space.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!createdServerId ? (
|
||||
@@ -287,7 +303,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
||||
{isSettingUp ? (
|
||||
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||
Setting up your server — installing Docker and dependencies...
|
||||
Setting up your server – installing Docker and dependencies…
|
||||
</div>
|
||||
) : isValidating && !validation ? (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
@@ -311,7 +327,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
||||
</div>
|
||||
{!isReady && !isSettingUp && (
|
||||
<AlertBlock type="info">
|
||||
Setup usually takes a minute after the server boots — this checks
|
||||
Setup runs after the server boots and this page checks for it
|
||||
automatically. You can retry setup, or use "Skip for now" above to
|
||||
continue and come back to this later.
|
||||
</AlertBlock>
|
||||
|
||||
@@ -39,8 +39,8 @@ export const WelcomeStep = ({ onNext }: Props) => {
|
||||
Let's get your first app <em className="not-italic">live</em>.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
A few steps — pick a plan, connect a server, ship something. You'll
|
||||
have a working URL by the end.
|
||||
It just takes a few steps: pick a plan, connect a server, ship
|
||||
something. You'll have a working URL by the end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Box, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().max(64).optional(),
|
||||
source: z.enum(["template", "image"]),
|
||||
template: z.enum(["base", "python", "node"]),
|
||||
image: z.string().max(255).optional(),
|
||||
serverId: z.string().nullable(),
|
||||
cpu: z.number().min(0.1).max(64),
|
||||
memoryMb: z.number().int().min(64).max(262_144),
|
||||
timeoutMinutes: z.number().min(1).max(1440),
|
||||
networkMode: z.enum(["isolated", "internet"]),
|
||||
envVars: z.string().optional(),
|
||||
})
|
||||
.refine((value) => value.source === "template" || !!value.image?.trim(), {
|
||||
message: "Image is required",
|
||||
path: ["image"],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const AddSandbox = ({ environmentId, projectId }: Props) => {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const { data: templates } = api.sandbox.templates.useQuery();
|
||||
const { mutateAsync, isPending, error } = api.sandbox.create.useMutation();
|
||||
|
||||
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
source: "template",
|
||||
template: "python",
|
||||
image: "",
|
||||
serverId: null,
|
||||
cpu: 1,
|
||||
memoryMb: 512,
|
||||
timeoutMinutes: 5,
|
||||
networkMode: "isolated",
|
||||
envVars: "",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
const source = form.watch("source");
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const sandbox = await mutateAsync({
|
||||
environmentId,
|
||||
name: values.name?.trim() || undefined,
|
||||
template: values.source === "template" ? values.template : undefined,
|
||||
image: values.source === "image" ? values.image?.trim() : undefined,
|
||||
serverId: values.serverId,
|
||||
cpu: values.cpu,
|
||||
memoryMb: values.memoryMb,
|
||||
timeoutMs: Math.round(values.timeoutMinutes * 60_000),
|
||||
networkMode: values.networkMode,
|
||||
envVars: values.envVars?.trim() || undefined,
|
||||
});
|
||||
toast.success("Sandbox created");
|
||||
await utils.sandbox.list.invalidate({ environmentId });
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
router.push(
|
||||
`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error creating the sandbox");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<PlusIcon className="size-4" />
|
||||
Create Sandbox
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl md:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Box className="size-5 text-muted-foreground" />
|
||||
New Sandbox
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
An ephemeral, resource-limited container you can drive through the
|
||||
API. It is removed automatically when its timeout expires.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <AlertBlock type="error">{error.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="add-sandbox-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-sandbox" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image</FormLabel>
|
||||
<Tabs value={field.value} onValueChange={field.onChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="template">Template</TabsTrigger>
|
||||
<TabsTrigger value="image">Custom image</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{source === "template" ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(templates ?? []).map((template) => (
|
||||
<SelectItem key={template.name} value={template.name}>
|
||||
<span className="capitalize">{template.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· {template.image}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder="alpine:3.20" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The image must contain a POSIX shell (sh) and tail.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(servers?.length ?? 0) > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server</FormLabel>
|
||||
<Select
|
||||
value={field.value ?? "dokploy"}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === "dokploy" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{showLocalOption && (
|
||||
<SelectItem value="dokploy">
|
||||
Dokploy Server
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cpu"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>CPU cores</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0.1}
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="memoryMb"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Memory (MB)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="64"
|
||||
min={64}
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeoutMinutes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Timeout (minutes)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="networkMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="isolated">
|
||||
Isolated (no network access)
|
||||
</SelectItem>
|
||||
<SelectItem value="internet">Internet access</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Sandboxes are never attached to dokploy-network or any
|
||||
application network.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="envVars"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={"KEY=value\nOTHER=value"}
|
||||
className="font-mono min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="add-sandbox-form"
|
||||
isLoading={isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const schema = z.object({
|
||||
minutes: z.number().min(1).max(1440),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
sandboxId: string;
|
||||
currentTimeoutMs: number;
|
||||
}
|
||||
|
||||
export const ExtendSandboxTimeout = ({
|
||||
sandboxId,
|
||||
currentTimeoutMs,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutateAsync, isPending } = api.sandbox.setTimeout.useMutation();
|
||||
const form = useForm<z.infer<typeof schema>>({
|
||||
defaultValues: {
|
||||
minutes: Math.max(1, Math.round(currentTimeoutMs / 60_000)),
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Clock className="size-4" />
|
||||
Extend timeout
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Extend timeout</DialogTitle>
|
||||
<DialogDescription>
|
||||
The sandbox will be killed this many minutes after its last
|
||||
activity.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="extend-sandbox-form"
|
||||
className="grid gap-4"
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
await mutateAsync({
|
||||
sandboxId,
|
||||
timeoutMs: Math.round(values.minutes * 60_000),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Timeout updated");
|
||||
await utils.sandbox.one.invalidate({ sandboxId });
|
||||
setOpen(false);
|
||||
})
|
||||
.catch(() => toast.error("Error updating the timeout"));
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="minutes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Timeout (minutes)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="extend-sandbox-form"
|
||||
isLoading={isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,185 +0,0 @@
|
||||
import { Eraser, Loader2, Play, Terminal } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Line = {
|
||||
kind: "cmd" | "stdout" | "stderr" | "exit" | "error" | "info";
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ServerMessage =
|
||||
| { type: "ready"; sandboxId: string }
|
||||
| { type: "stdout"; data: string }
|
||||
| { type: "stderr"; data: string }
|
||||
| { type: "exit"; exitCode: number; timedOut: boolean }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
interface Props {
|
||||
sandboxId: string;
|
||||
enabled: boolean;
|
||||
workdir: string;
|
||||
}
|
||||
|
||||
const lineClass: Record<Line["kind"], string> = {
|
||||
cmd: "text-primary font-semibold",
|
||||
stdout: "text-foreground",
|
||||
stderr: "text-orange-500",
|
||||
exit: "text-muted-foreground italic",
|
||||
error: "text-destructive",
|
||||
info: "text-muted-foreground",
|
||||
};
|
||||
|
||||
export const SandboxConsole = ({ sandboxId, enabled, workdir }: Props) => {
|
||||
const [lines, setLines] = useState<Line[]>([]);
|
||||
const [command, setCommand] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const outputRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const append = useCallback((line: Line) => {
|
||||
setLines((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (
|
||||
last &&
|
||||
(line.kind === "stdout" || line.kind === "stderr") &&
|
||||
last.kind === line.kind &&
|
||||
!last.text.endsWith("\n")
|
||||
) {
|
||||
return [...prev.slice(0, -1), { ...last, text: last.text + line.text }];
|
||||
}
|
||||
return [...prev, line];
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${protocol}//${window.location.host}/sandbox-exec?sandboxId=${sandboxId}`,
|
||||
);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = (event) => {
|
||||
setConnected(false);
|
||||
setRunning(false);
|
||||
if (event.code >= 4000) {
|
||||
append({ kind: "error", text: event.reason || "Connection closed" });
|
||||
}
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
let message: ServerMessage;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type === "stdout")
|
||||
append({ kind: "stdout", text: message.data });
|
||||
else if (message.type === "stderr")
|
||||
append({ kind: "stderr", text: message.data });
|
||||
else if (message.type === "exit") {
|
||||
append({
|
||||
kind: "exit",
|
||||
text: message.timedOut
|
||||
? `[timed out, exit code ${message.exitCode}]`
|
||||
: `[exit code ${message.exitCode}]`,
|
||||
});
|
||||
setRunning(false);
|
||||
} else if (message.type === "error") {
|
||||
append({ kind: "error", text: message.message });
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [sandboxId, enabled, append]);
|
||||
|
||||
useEffect(() => {
|
||||
outputRef.current?.scrollTo({ top: outputRef.current.scrollHeight });
|
||||
}, [lines]);
|
||||
|
||||
const run = () => {
|
||||
const cmd = command.trim();
|
||||
const ws = wsRef.current;
|
||||
if (!cmd || !ws || ws.readyState !== WebSocket.OPEN || running) return;
|
||||
append({ kind: "cmd", text: `$ ${cmd}` });
|
||||
setRunning(true);
|
||||
setCommand("");
|
||||
ws.send(JSON.stringify({ type: "exec", cmd }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Terminal className="size-5 text-muted-foreground" />
|
||||
Console
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{enabled
|
||||
? connected
|
||||
? "connected"
|
||||
: "connecting..."
|
||||
: "sandbox is not running"}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLines([])}
|
||||
disabled={lines.length === 0}
|
||||
>
|
||||
<Eraser className="size-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div
|
||||
ref={outputRef}
|
||||
className="h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 font-mono text-xs whitespace-pre-wrap break-words"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
Commands run with `sh -c` inside {workdir}. Output streams here.
|
||||
</span>
|
||||
) : (
|
||||
lines.map((line, index) => (
|
||||
<div key={index} className={cn(lineClass[line.kind])}>
|
||||
{line.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
run();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="echo hello"
|
||||
className="font-mono"
|
||||
disabled={!enabled || !connected || running}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button type="submit" disabled={!enabled || !connected || running}>
|
||||
{running ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type SandboxStatus = "creating" | "running" | "killed" | "error";
|
||||
|
||||
const variants: Record<SandboxStatus, "green" | "yellow" | "blank" | "red"> = {
|
||||
running: "green",
|
||||
creating: "yellow",
|
||||
killed: "blank",
|
||||
error: "red",
|
||||
};
|
||||
|
||||
export const SandboxStatusBadge = ({ status }: { status: SandboxStatus }) => (
|
||||
<Badge variant={variants[status]} className="capitalize">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
@@ -1,134 +0,0 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Box, ServerIcon, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
import { SandboxStatusBadge } from "./sandbox-status-badge";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const formatExpiresIn = (expiresAt: Date | null, status: string) => {
|
||||
if (status !== "running" || !expiresAt) return "—";
|
||||
if (expiresAt.getTime() <= Date.now()) return "expired";
|
||||
return formatDistanceToNow(expiresAt, { addSuffix: true });
|
||||
};
|
||||
|
||||
export const ShowSandboxes = ({ projectId, environmentId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: sandboxes, isLoading } = api.sandbox.list.useQuery(
|
||||
{ environmentId },
|
||||
{ refetchInterval: 10_000 },
|
||||
);
|
||||
const { mutateAsync: kill } = api.sandbox.kill.useMutation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sandboxes || sandboxes.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-2">
|
||||
<Box className="size-8 text-muted-foreground" />
|
||||
<span className="font-medium text-muted-foreground">
|
||||
No sandboxes yet. Create one from the UI or through the API.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Image</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Server</TableHead>
|
||||
<TableHead>CPU / RAM</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sandboxes.map((sandbox) => (
|
||||
<TableRow key={sandbox.sandboxId}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{sandbox.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{sandbox.image}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SandboxStatusBadge status={sandbox.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<ServerIcon className="size-3.5 text-muted-foreground" />
|
||||
{sandbox.server?.name ?? "Dokploy Server"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{sandbox.cpu} vCPU / {sandbox.memoryMb} MB
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatExpiresIn(sandbox.expiresAt, sandbox.status)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<DateTooltip date={sandbox.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{sandbox.status === "running" &&
|
||||
permissions?.deployment.create && (
|
||||
<DialogAction
|
||||
title="Kill sandbox"
|
||||
description={`The container for "${sandbox.name}" will be stopped and removed.`}
|
||||
onClick={async () => {
|
||||
await kill({ sandboxId: sandbox.sandboxId })
|
||||
.then(() => {
|
||||
toast.success("Sandbox killed");
|
||||
utils.sandbox.list.invalidate({ environmentId });
|
||||
})
|
||||
.catch(() =>
|
||||
toast.error("Error killing the sandbox"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CreditCard, FileText } from "lucide-react";
|
||||
import { CreditCard, FileText, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
@@ -17,6 +17,11 @@ const navigationItems = [
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Loader2,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -83,6 +84,11 @@ const navigationItems = [
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
@@ -105,13 +111,53 @@ export const ShowBilling = () => {
|
||||
api.stripe.upgradeSubscription.useMutation();
|
||||
const { mutateAsync: updateInvoiceNotifications } =
|
||||
api.stripe.updateInvoiceNotifications.useMutation();
|
||||
const { mutateAsync: startFreeTrial, isPending: isStartingTrial } =
|
||||
const { mutateAsync: startFreeTrial } =
|
||||
api.stripe.startFreeTrial.useMutation();
|
||||
const utils = api.useUtils();
|
||||
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
|
||||
const [switchingTrial, setSwitchingTrial] = useState(false);
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
const handleSwitchTrialPlan = async (
|
||||
tier: "hobby" | "startup",
|
||||
isAnnualPlan: boolean,
|
||||
label?: string,
|
||||
) => {
|
||||
const planName =
|
||||
label ??
|
||||
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
|
||||
|
||||
const switchPlan = async () => {
|
||||
await upgradeSubscription({
|
||||
tier,
|
||||
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||
isAnnual: isAnnualPlan,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
utils.user.get.invalidate(),
|
||||
utils.server.count.invalidate(),
|
||||
]);
|
||||
};
|
||||
|
||||
setSwitchingTrial(true);
|
||||
await toast
|
||||
.promise(switchPlan(), {
|
||||
loading: `Switching to ${planName}...`,
|
||||
success: `Your trial is now on ${planName}`,
|
||||
error: (error) =>
|
||||
error instanceof Error ? error.message : "Error switching plan",
|
||||
})
|
||||
.unwrap()
|
||||
.catch(() => undefined);
|
||||
setSwitchingTrial(false);
|
||||
};
|
||||
|
||||
const handleStartTrial = async (tier: "hobby" | "startup") => {
|
||||
setTrialTier(tier);
|
||||
try {
|
||||
await startFreeTrial();
|
||||
await startFreeTrial({ tier });
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
@@ -122,6 +168,8 @@ export const ShowBilling = () => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
);
|
||||
} finally {
|
||||
setTrialTier(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,12 +218,23 @@ export const ShowBilling = () => {
|
||||
|
||||
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
|
||||
const products = data?.products.filter((product) => {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
const interval = product?.default_price?.recurring?.interval;
|
||||
return isAnnual ? interval === "year" : interval === "month";
|
||||
});
|
||||
|
||||
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
|
||||
const canStartTrial =
|
||||
!isEnterpriseCloud &&
|
||||
!!billingStatus &&
|
||||
!billingStatus.plan &&
|
||||
!billingStatus.isOnTrial &&
|
||||
!billingStatus.hasUsedTrial;
|
||||
/** Checkout would create a second subscription alongside a running trial. */
|
||||
const canSubscribe =
|
||||
!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 &&
|
||||
!billingStatus?.isOnTrial;
|
||||
const maxServers = admin?.user.serversQuantity ?? 1;
|
||||
const percentage = ((servers ?? 0) / maxServers) * 100;
|
||||
const safePercentage = Math.min(percentage, 100);
|
||||
@@ -289,6 +348,11 @@ export const ShowBilling = () => {
|
||||
<Badge className="capitalize" variant="secondary">
|
||||
{billingStatus.plan ?? "Trial"}
|
||||
</Badge>
|
||||
{billingStatus.plan && (
|
||||
<Badge variant="outline">
|
||||
{billingStatus.isAnnual ? "Annual" : "Monthly"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{billingStatus.isOnTrial
|
||||
@@ -296,67 +360,94 @@ export const ShowBilling = () => {
|
||||
billingStatus.trialEndsAt
|
||||
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
|
||||
: ""
|
||||
}${
|
||||
billingStatus.hasPaymentMethod
|
||||
? " · billing starts automatically"
|
||||
: " · add a card to keep your servers"
|
||||
}`
|
||||
: "You're subscribed and billed automatically."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{billingStatus.isOnTrial &&
|
||||
admin?.user.stripeCustomerId && (
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{billingStatus.isOnTrial &&
|
||||
useNewPricing &&
|
||||
billingStatus.plan &&
|
||||
billingStatus.plan !== "legacy" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={switchingTrial}
|
||||
onClick={() =>
|
||||
handleSwitchTrialPlan(
|
||||
billingStatus.plan === "startup"
|
||||
? "hobby"
|
||||
: "startup",
|
||||
billingStatus.isAnnual,
|
||||
)
|
||||
}
|
||||
>
|
||||
{billingStatus.plan === "startup"
|
||||
? "Switch to Hobby"
|
||||
: "Switch to Startup"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={switchingTrial}
|
||||
onClick={() =>
|
||||
handleSwitchTrialPlan(
|
||||
billingStatus.plan as "hobby" | "startup",
|
||||
!billingStatus.isAnnual,
|
||||
billingStatus.isAnnual
|
||||
? "monthly billing"
|
||||
: "annual billing",
|
||||
)
|
||||
}
|
||||
>
|
||||
{billingStatus.isAnnual
|
||||
? "Switch to monthly"
|
||||
: "Switch to annual"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant={
|
||||
billingStatus.isOnTrial &&
|
||||
!billingStatus.hasPaymentMethod
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
onClick={async () => {
|
||||
const session = await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
Add payment method
|
||||
{billingStatus.isOnTrial &&
|
||||
!billingStatus.hasPaymentMethod
|
||||
? "Add payment method"
|
||||
: "Manage Subscription"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
billingStatus &&
|
||||
!billingStatus.plan &&
|
||||
!billingStatus.isOnTrial &&
|
||||
!billingStatus.hasUsedTrial && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
7-day free trial
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No credit card required — cancel anytime.
|
||||
</span>
|
||||
<ul className="flex flex-col gap-1 mt-1">
|
||||
{[
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"Community support",
|
||||
].map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isStartingTrial}
|
||||
onClick={handleStartTrial}
|
||||
>
|
||||
Start trial
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canStartTrial && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
7-day free trial on any plan
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No credit card required — pick Hobby or Startup below.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||
@@ -933,68 +1024,71 @@ export const ShowBilling = () => {
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Servers:
|
||||
</span>
|
||||
<Button
|
||||
disabled={hobbyServerQuantity <= 1}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) => Math.max(1, q - 1))
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={hobbyServerQuantity}
|
||||
onChange={(e) =>
|
||||
setHobbyServerQuantity(
|
||||
Math.max(
|
||||
1,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setHobbyServerQuantity((q) => q + 1)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{admin?.user.stripeCustomerId && (
|
||||
{canSubscribe && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Servers:
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
disabled={hobbyServerQuantity <= 1}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) =>
|
||||
Math.max(1, q - 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
Manage Subscription
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={hobbyServerQuantity}
|
||||
onChange={(e) =>
|
||||
setHobbyServerQuantity(
|
||||
Math.max(
|
||||
1,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{canStartTrial && (
|
||||
<Button
|
||||
className="w-full"
|
||||
isLoading={trialTier === "hobby"}
|
||||
disabled={trialTier !== null}
|
||||
onClick={() => handleStartTrial("hobby")}
|
||||
>
|
||||
Start 7-day free trial
|
||||
</Button>
|
||||
)}
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={canStartTrial ? "outline" : "default"}
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1067,85 +1161,83 @@ export const ShowBilling = () => {
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={
|
||||
startupServerQuantity <=
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) =>
|
||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={startupServerQuantity}
|
||||
onChange={(e) =>
|
||||
setStartupServerQuantity(
|
||||
Math.max(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || STARTUP_SERVERS_INCLUDED,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
{canSubscribe && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout(
|
||||
"startup",
|
||||
data!.startupProductId!,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
startupServerQuantity <
|
||||
startupServerQuantity <=
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) =>
|
||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<NumberInput
|
||||
value={startupServerQuantity}
|
||||
onChange={(e) =>
|
||||
setStartupServerQuantity(
|
||||
Math.max(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || STARTUP_SERVERS_INCLUDED,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{canStartTrial && (
|
||||
<Button
|
||||
className="w-full"
|
||||
isLoading={trialTier === "startup"}
|
||||
disabled={trialTier !== null}
|
||||
onClick={() => handleStartTrial("startup")}
|
||||
>
|
||||
Start 7-day free trial
|
||||
</Button>
|
||||
)}
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={canStartTrial ? "outline" : "default"}
|
||||
onClick={() =>
|
||||
handleCheckout(
|
||||
"startup",
|
||||
data!.startupProductId!,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
startupServerQuantity < STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1337,31 +1429,17 @@ export const ShowBilling = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-4 w-full">
|
||||
{admin?.user.stripeCustomerId && (
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
handleCheckout("legacy", product.id);
|
||||
}}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Manage Subscription
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
handleCheckout("legacy", product.id);
|
||||
}}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
name: "Subscription",
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
variant: "default" | "secondary" | "destructive" | "outline";
|
||||
}
|
||||
> = {
|
||||
pending: {
|
||||
label: "Pending",
|
||||
icon: <Clock className="size-3" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
provisioning: {
|
||||
label: "Provisioning",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
configuring: {
|
||||
label: "Installing Dokploy",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
icon: <CheckCircle2 className="size-3" />,
|
||||
variant: "default",
|
||||
},
|
||||
error: {
|
||||
label: "Error",
|
||||
icon: <XCircle className="size-3" />,
|
||||
variant: "destructive",
|
||||
},
|
||||
terminating: {
|
||||
label: "Terminating",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
terminated: {
|
||||
label: "Terminated",
|
||||
icon: <AlertCircle className="size-3" />,
|
||||
variant: "outline",
|
||||
},
|
||||
};
|
||||
|
||||
function formatSpecs(cpus: number, memoryGb: number, storageGb: number) {
|
||||
return `${cpus} vCPU · ${memoryGb} GB RAM · ${storageGb} GB NVMe`;
|
||||
}
|
||||
|
||||
function formatPrice(priceCents: number) {
|
||||
return `$${(priceCents / 100).toFixed(2)}/mo`;
|
||||
}
|
||||
|
||||
const CONTINENT_ORDER = ["Europe", "Americas", "Asia-Pacific"];
|
||||
|
||||
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string>("");
|
||||
const [selectedZone, setSelectedZone] = useState<string>("");
|
||||
|
||||
const { data: plans, isLoading: loadingPlans } =
|
||||
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
|
||||
const { data: zones, isLoading: loadingZones } =
|
||||
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
|
||||
|
||||
const isLoadingOptions = loadingPlans || loadingZones;
|
||||
|
||||
const purchase = api.managedServer.purchase.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server order placed! Provisioning will take ~5 minutes.");
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const plan = plans?.find((p) => p.id === selectedPlan);
|
||||
|
||||
const groupedZones = zones?.reduce<Record<string, typeof zones>>((acc, z) => {
|
||||
if (!acc[z.continent]) acc[z.continent] = [];
|
||||
acc[z.continent]!.push(z);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="size-4 mr-2" />
|
||||
Order Server
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Order a Managed Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
We'll provision and configure a server for you automatically. Ready
|
||||
in ~5 minutes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{isLoadingOptions ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading available plans...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Plan cards */}
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{plans?.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPlan(p.id)}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-lg border p-3 text-left transition-colors",
|
||||
selectedPlan === p.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-sm">{p.name}</p>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{formatPrice(p.priceCents)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSpecs(p.cpus, p.memoryGb, p.storageGb)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone selector grouped by continent */}
|
||||
<div className="space-y-2">
|
||||
<Label>Location</Label>
|
||||
<Select value={selectedZone} onValueChange={setSelectedZone}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a location..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
className="max-h-64 overflow-y-auto"
|
||||
>
|
||||
{CONTINENT_ORDER.filter((c) => groupedZones?.[c]).map((continent) => (
|
||||
<div key={continent}>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{continent}
|
||||
</div>
|
||||
{groupedZones?.[continent]?.map((z) => (
|
||||
<SelectItem key={z.id} value={z.id}>
|
||||
{z.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{plan && selectedZone && (
|
||||
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Plan</span>
|
||||
<span className="font-medium">{plan.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Specs</span>
|
||||
<span className="font-medium">
|
||||
{formatSpecs(plan.cpus, plan.memoryGb, plan.storageGb)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Price</span>
|
||||
<span className="font-medium">{formatPrice(plan.priceCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Zone</span>
|
||||
<span className="font-medium">
|
||||
{zones?.find((z) => z.id === selectedZone)?.description ?? selectedZone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!selectedPlan || !selectedZone || purchase.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedPlan || !selectedZone) return;
|
||||
purchase.mutate({
|
||||
plan: selectedPlan,
|
||||
zone: selectedZone,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{purchase.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Placing order...
|
||||
</>
|
||||
) : (
|
||||
"Order Server"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
hobby: "Hobby",
|
||||
starter: "Starter",
|
||||
pro: "Pro",
|
||||
business: "Business",
|
||||
};
|
||||
|
||||
export const ShowManagedServers = () => {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: servers, isLoading } = api.managedServer.list.useQuery();
|
||||
|
||||
const syncStatus = api.managedServer.syncStatus.useMutation({
|
||||
onSuccess: () => utils.managedServer.list.invalidate(),
|
||||
});
|
||||
|
||||
const reconnect = api.managedServer.reconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server reconnected successfully.");
|
||||
utils.managedServer.list.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteServer = api.managedServer.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server terminated.");
|
||||
utils.managedServer.list.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Server className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and servers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = router.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">Managed Servers</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Servers provisioned and managed by Dokploy Cloud
|
||||
</p>
|
||||
</div>
|
||||
<OrderServerDialog
|
||||
onSuccess={() => utils.managedServer.list.invalidate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : servers?.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg border-dashed">
|
||||
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">No managed servers yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Order a server and we'll provision and configure it for you
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{servers?.map((s) => {
|
||||
const status =
|
||||
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
|
||||
const isProvisioning = [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
].includes(s.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.managedServerId}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="size-5 text-muted-foreground shrink-0" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{PLAN_LABELS[s.plan] ?? s.plan}
|
||||
</span>
|
||||
<Badge
|
||||
variant={status?.variant}
|
||||
className="flex items-center gap-1 text-xs h-5"
|
||||
>
|
||||
{status?.icon}
|
||||
{status?.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.hostname ?? ""}
|
||||
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
|
||||
{s.zone ? ` · ${s.zone}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isProvisioning && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
syncStatus.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
disabled={syncStatus.isPending}
|
||||
>
|
||||
<Loader2
|
||||
className={cn(
|
||||
"size-4",
|
||||
syncStatus.isPending && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{s.status === "ready" && !s.serverId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
reconnect.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
disabled={reconnect.isPending}
|
||||
>
|
||||
{reconnect.isPending ? (
|
||||
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
{s.status === "ready" && s.server && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
href={`/dashboard/settings/server?serverId=${s.serverId}`}
|
||||
>
|
||||
<ExternalLink className="size-3.5 mr-1.5" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<DialogAction
|
||||
title="Terminate Server"
|
||||
description="This will permanently destroy the server and all data on it. This action cannot be undone."
|
||||
type="destructive"
|
||||
onClick={() =>
|
||||
deleteServer.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -30,6 +30,7 @@ const dockerComposeServices = [
|
||||
{ label: "networks", type: "keyword", info: "Define networks" },
|
||||
{ label: "configs", type: "keyword", info: "Define configuration files" },
|
||||
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
||||
{ label: "models", type: "keyword", info: "Define AI models" },
|
||||
].map((opt) => ({
|
||||
...opt,
|
||||
apply: (
|
||||
@@ -83,6 +84,11 @@ const dockerComposeServiceOptions = [
|
||||
},
|
||||
{ label: "restart", type: "keyword", info: "Restart policy" },
|
||||
{ label: "networks", type: "keyword", info: "Networks to join" },
|
||||
{
|
||||
label: "models",
|
||||
type: "keyword",
|
||||
info: "AI models to use, referencing top-level models",
|
||||
},
|
||||
].map((opt) => ({
|
||||
...opt,
|
||||
apply: (
|
||||
|
||||
@@ -35,6 +35,16 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
"models": {
|
||||
"id": "#/properties/models",
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z0-9._-]+$": {
|
||||
"$ref": "#/definitions/model"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"networks": {
|
||||
"id": "#/properties/networks",
|
||||
"type": "object",
|
||||
@@ -335,6 +345,30 @@
|
||||
"mem_swappiness": { "type": ["integer", "string"] },
|
||||
"memswap_limit": { "type": ["number", "string"] },
|
||||
"network_mode": { "type": "string" },
|
||||
"models": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/definitions/list_of_strings" },
|
||||
{
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z0-9._-]+$": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint_var": { "type": "string" },
|
||||
"model_var": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"patternProperties": { "^x-": {} }
|
||||
},
|
||||
{ "type": "null" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"networks": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/definitions/list_of_strings" },
|
||||
@@ -890,6 +924,23 @@
|
||||
"patternProperties": { "^x-": {} }
|
||||
},
|
||||
|
||||
"model": {
|
||||
"id": "#/definitions/model",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"model": { "type": "string" },
|
||||
"context_size": { "type": "integer" },
|
||||
"runtime_flags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["model"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": { "^x-": {} }
|
||||
},
|
||||
|
||||
"command": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
|
||||
CREATE TABLE "managed_server" (
|
||||
"managedServerId" text PRIMARY KEY NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"serverId" text,
|
||||
"plan" text NOT NULL,
|
||||
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
|
||||
"providerVmId" text,
|
||||
"zone" text NOT NULL,
|
||||
"sshKeyId" text,
|
||||
"ipAddress" text,
|
||||
"hostname" text,
|
||||
"stripeSubscriptionId" text,
|
||||
"stripePriceId" text,
|
||||
"errorMessage" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"updatedAt" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_sshKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("sshKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
|
||||
@@ -1,28 +0,0 @@
|
||||
CREATE TYPE "public"."sandboxNetworkMode" AS ENUM('isolated', 'internet');--> statement-breakpoint
|
||||
CREATE TYPE "public"."sandboxStatus" AS ENUM('creating', 'running', 'killed', 'error');--> statement-breakpoint
|
||||
CREATE TABLE "sandbox" (
|
||||
"sandboxId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"environmentId" text NOT NULL,
|
||||
"serverId" text,
|
||||
"image" text NOT NULL,
|
||||
"template" text,
|
||||
"containerId" text,
|
||||
"status" "sandboxStatus" DEFAULT 'creating' NOT NULL,
|
||||
"cpu" real DEFAULT 1 NOT NULL,
|
||||
"memoryMb" integer DEFAULT 512 NOT NULL,
|
||||
"pidsLimit" integer DEFAULT 256 NOT NULL,
|
||||
"timeoutMs" integer DEFAULT 300000 NOT NULL,
|
||||
"expiresAt" timestamp with time zone,
|
||||
"lastActivityAt" timestamp with time zone,
|
||||
"networkMode" "sandboxNetworkMode" DEFAULT 'isolated' NOT NULL,
|
||||
"envVars" text,
|
||||
"workdir" text DEFAULT '/home/user' NOT NULL,
|
||||
"user" text,
|
||||
"runtime" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"killedAt" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "3c488fa5-f394-4c43-bd21-3854f6b301ab",
|
||||
"id": "2e24da90-0d77-4655-a4a3-fb4c0e22f364",
|
||||
"prevId": "c9ad226a-7a8f-4c3b-8f04-fef6bccf9bfb",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -4310,6 +4310,151 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.managed_server": {
|
||||
"name": "managed_server",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"managedServerId": {
|
||||
"name": "managedServerId",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"organizationId": {
|
||||
"name": "organizationId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"serverId": {
|
||||
"name": "serverId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"plan": {
|
||||
"name": "plan",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "managedServerStatus",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"providerVmId": {
|
||||
"name": "providerVmId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"zone": {
|
||||
"name": "zone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sshKeyId": {
|
||||
"name": "sshKeyId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ipAddress": {
|
||||
"name": "ipAddress",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"hostname": {
|
||||
"name": "hostname",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"stripeSubscriptionId": {
|
||||
"name": "stripeSubscriptionId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"stripePriceId": {
|
||||
"name": "stripePriceId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"errorMessage": {
|
||||
"name": "errorMessage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"managed_server_organizationId_organization_id_fk": {
|
||||
"name": "managed_server_organizationId_organization_id_fk",
|
||||
"tableFrom": "managed_server",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"organizationId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"managed_server_serverId_server_serverId_fk": {
|
||||
"name": "managed_server_serverId_server_serverId_fk",
|
||||
"tableFrom": "managed_server",
|
||||
"tableTo": "server",
|
||||
"columnsFrom": [
|
||||
"serverId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"serverId"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"managed_server_sshKeyId_ssh-key_sshKeyId_fk": {
|
||||
"name": "managed_server_sshKeyId_ssh-key_sshKeyId_fk",
|
||||
"tableFrom": "managed_server",
|
||||
"tableTo": "ssh-key",
|
||||
"columnsFrom": [
|
||||
"sshKeyId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"sshKeyId"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mariadb": {
|
||||
"name": "mariadb",
|
||||
"schema": "",
|
||||
@@ -7317,181 +7462,6 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sandbox": {
|
||||
"name": "sandbox",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"sandboxId": {
|
||||
"name": "sandboxId",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"environmentId": {
|
||||
"name": "environmentId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"serverId": {
|
||||
"name": "serverId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"template": {
|
||||
"name": "template",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"containerId": {
|
||||
"name": "containerId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "sandboxStatus",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'creating'"
|
||||
},
|
||||
"cpu": {
|
||||
"name": "cpu",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"memoryMb": {
|
||||
"name": "memoryMb",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 512
|
||||
},
|
||||
"pidsLimit": {
|
||||
"name": "pidsLimit",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 256
|
||||
},
|
||||
"timeoutMs": {
|
||||
"name": "timeoutMs",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 300000
|
||||
},
|
||||
"expiresAt": {
|
||||
"name": "expiresAt",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"lastActivityAt": {
|
||||
"name": "lastActivityAt",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"networkMode": {
|
||||
"name": "networkMode",
|
||||
"type": "sandboxNetworkMode",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'isolated'"
|
||||
},
|
||||
"envVars": {
|
||||
"name": "envVars",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"workdir": {
|
||||
"name": "workdir",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'/home/user'"
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"runtime": {
|
||||
"name": "runtime",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"killedAt": {
|
||||
"name": "killedAt",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sandbox_environmentId_environment_environmentId_fk": {
|
||||
"name": "sandbox_environmentId_environment_environmentId_fk",
|
||||
"tableFrom": "sandbox",
|
||||
"tableTo": "environment",
|
||||
"columnsFrom": [
|
||||
"environmentId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"environmentId"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"sandbox_serverId_server_serverId_fk": {
|
||||
"name": "sandbox_serverId_server_serverId_fk",
|
||||
"tableFrom": "sandbox",
|
||||
"tableTo": "server",
|
||||
"columnsFrom": [
|
||||
"serverId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"serverId"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.schedule": {
|
||||
"name": "schedule",
|
||||
"schema": "",
|
||||
@@ -9166,6 +9136,19 @@
|
||||
"gitea"
|
||||
]
|
||||
},
|
||||
"public.managedServerStatus": {
|
||||
"name": "managedServerStatus",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
"ready",
|
||||
"error",
|
||||
"terminating",
|
||||
"terminated"
|
||||
]
|
||||
},
|
||||
"public.mountType": {
|
||||
"name": "mountType",
|
||||
"schema": "public",
|
||||
@@ -9248,24 +9231,6 @@
|
||||
"cloud"
|
||||
]
|
||||
},
|
||||
"public.sandboxNetworkMode": {
|
||||
"name": "sandboxNetworkMode",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"isolated",
|
||||
"internet"
|
||||
]
|
||||
},
|
||||
"public.sandboxStatus": {
|
||||
"name": "sandboxStatus",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"creating",
|
||||
"running",
|
||||
"killed",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"public.scheduleType": {
|
||||
"name": "scheduleType",
|
||||
"schema": "public",
|
||||
|
||||
@@ -1384,8 +1384,8 @@
|
||||
{
|
||||
"idx": 197,
|
||||
"version": "7",
|
||||
"when": 1789022852219,
|
||||
"tag": "0197_demonic_the_initiative",
|
||||
"when": 1790609715152,
|
||||
"tag": "0197_aberrant_psynapse",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "16.3.0",
|
||||
"next": "16.3.4",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"node-os-utils": "2.0.1",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
sendInvoiceEmail,
|
||||
sendPaymentFailedEmail,
|
||||
} from "@/server/utils/stripe-notifications";
|
||||
import { sendTrialExpiringEmail } from "@/server/utils/trial-notifications";
|
||||
|
||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||
|
||||
@@ -72,6 +73,7 @@ export default async function handler(
|
||||
"invoice.payment_failed",
|
||||
"customer.deleted",
|
||||
"checkout.session.completed",
|
||||
"customer.subscription.trial_will_end",
|
||||
];
|
||||
|
||||
if (!webhooksAllowed.includes(event.type)) {
|
||||
@@ -110,6 +112,12 @@ export default async function handler(
|
||||
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.trial_will_end": {
|
||||
const trialingSubscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await sendTrialExpiringEmail(stripe, trialingSubscription, event.created);
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.created": {
|
||||
const newSubscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
@@ -176,7 +184,10 @@ export default async function handler(
|
||||
break;
|
||||
}
|
||||
|
||||
if (newSubscription.status === "active") {
|
||||
if (
|
||||
newSubscription.status === "active" ||
|
||||
newSubscription.status === "trialing"
|
||||
) {
|
||||
const serversQuantity = getSubscriptionServersQuantity(
|
||||
newSubscription?.items?.data ?? [],
|
||||
);
|
||||
@@ -209,9 +220,12 @@ export default async function handler(
|
||||
case "invoice.payment_succeeded": {
|
||||
const newInvoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
newInvoice.subscription as string,
|
||||
);
|
||||
const subscriptionId = getInvoiceSubscriptionId(newInvoice);
|
||||
if (!subscriptionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
if (subscription.status !== "active") {
|
||||
console.log(
|
||||
@@ -220,6 +234,14 @@ export default async function handler(
|
||||
break;
|
||||
}
|
||||
|
||||
const admin = await findUserByStripeCustomerId(
|
||||
subscription.customer as string,
|
||||
);
|
||||
|
||||
if (!admin) {
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
|
||||
const serversQuantity = getSubscriptionServersQuantity(
|
||||
subscription?.items?.data ?? [],
|
||||
);
|
||||
@@ -233,18 +255,10 @@ export default async function handler(
|
||||
),
|
||||
);
|
||||
|
||||
const admin = await findUserByStripeCustomerId(
|
||||
subscription.customer as string,
|
||||
);
|
||||
|
||||
if (!admin) {
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
if (admin.isEnterpriseCloud) {
|
||||
break;
|
||||
}
|
||||
const newServersQuantity = admin.serversQuantity;
|
||||
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
||||
await updateServersBasedOnQuantity(admin.id, serversQuantity);
|
||||
|
||||
if (admin.sendInvoiceNotifications) {
|
||||
await sendInvoiceEmail(newInvoice, admin);
|
||||
@@ -255,9 +269,12 @@ export default async function handler(
|
||||
case "invoice.payment_failed": {
|
||||
const newInvoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
newInvoice.subscription as string,
|
||||
);
|
||||
const subscriptionId = getInvoiceSubscriptionId(newInvoice);
|
||||
if (!subscriptionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
if (subscription.status !== "active") {
|
||||
const admin = await findUserByStripeCustomerId(
|
||||
newInvoice.customer as string,
|
||||
@@ -351,6 +368,12 @@ const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
|
||||
return userResult;
|
||||
};
|
||||
|
||||
const getInvoiceSubscriptionId = (invoice: Stripe.Invoice): string | null => {
|
||||
const subscription = invoice.subscription;
|
||||
if (typeof subscription === "string") return subscription;
|
||||
return subscription?.id ?? null;
|
||||
};
|
||||
|
||||
const activateServer = async (serverId: string) => {
|
||||
await db
|
||||
.update(server)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import {
|
||||
Ban,
|
||||
Box,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronsUpDown,
|
||||
@@ -1063,14 +1062,6 @@ const EnvironmentPage = (
|
||||
<ProjectEnvironment projectId={projectId}>
|
||||
<Button variant="outline">Project Environment</Button>
|
||||
</ProjectEnvironment>
|
||||
<Link
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Box className="size-4" />
|
||||
Sandboxes
|
||||
</Button>
|
||||
</Link>
|
||||
{permissions?.service.create && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
-230
@@ -1,230 +0,0 @@
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import { ArrowLeft, Box, Skull, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
GetServerSidePropsContext,
|
||||
InferGetServerSidePropsType,
|
||||
} from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import type { ReactElement } from "react";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { ExtendSandboxTimeout } from "@/components/dashboard/sandbox/extend-sandbox-timeout";
|
||||
import { SandboxConsole } from "@/components/dashboard/sandbox/sandbox-console";
|
||||
import { SandboxStatusBadge } from "@/components/dashboard/sandbox/sandbox-status-badge";
|
||||
import { formatExpiresIn } from "@/components/dashboard/sandbox/show-sandboxes";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const Field = ({ label, value }: { label: string; value: React.ReactNode }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-medium break-all">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SandboxPage = (
|
||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||
) => {
|
||||
const { sandboxId } = props;
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { projectId, environmentId } = router.query as {
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
const { data, refetch } = api.sandbox.one.useQuery(
|
||||
{ sandboxId },
|
||||
{ refetchInterval: 10_000 },
|
||||
);
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { mutateAsync: kill, isPending: isKilling } =
|
||||
api.sandbox.kill.useMutation();
|
||||
const { mutateAsync: remove } = api.sandbox.remove.useMutation();
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const listHref = `/dashboard/project/${projectId}/environment/${environmentId}/sandboxes`;
|
||||
const isRunning = data?.status === "running";
|
||||
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<AdvanceBreadcrumb />
|
||||
<Head>
|
||||
<title>
|
||||
Sandbox: {data?.name} - {data?.environment?.project?.name} | {appName}
|
||||
</title>
|
||||
</Head>
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader className="flex flex-row flex-wrap justify-between items-start gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Box className="size-6 text-muted-foreground" />
|
||||
{data?.name}
|
||||
{data && <SandboxStatusBadge status={data.status} />}
|
||||
</CardTitle>
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{data?.sandboxId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge>{data?.server?.name || "Dokploy Server"}</Badge>
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
<Link href={listHref}>
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="size-4" />
|
||||
Sandboxes
|
||||
</Button>
|
||||
</Link>
|
||||
{isRunning && permissions?.deployment.create && (
|
||||
<ExtendSandboxTimeout
|
||||
sandboxId={sandboxId}
|
||||
currentTimeoutMs={data?.timeoutMs ?? 300_000}
|
||||
/>
|
||||
)}
|
||||
{isRunning && permissions?.deployment.create && (
|
||||
<DialogAction
|
||||
title="Kill sandbox"
|
||||
description="The container will be stopped and removed. Files inside it are lost."
|
||||
onClick={async () => {
|
||||
await kill({ sandboxId })
|
||||
.then(async () => {
|
||||
toast.success("Sandbox killed");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => toast.error("Error killing the sandbox"));
|
||||
}}
|
||||
>
|
||||
<Button variant="destructive" isLoading={isKilling}>
|
||||
<Skull className="size-4" />
|
||||
Kill
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DialogAction
|
||||
title="Delete sandbox"
|
||||
description="Removes the sandbox record (and its container if still running)."
|
||||
onClick={async () => {
|
||||
await remove({ sandboxId })
|
||||
.then(async () => {
|
||||
toast.success("Sandbox deleted");
|
||||
await utils.sandbox.list.invalidate({
|
||||
environmentId,
|
||||
});
|
||||
router.push(listHref);
|
||||
})
|
||||
.catch(() => toast.error("Error deleting the sandbox"));
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6 py-8 border-t">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<Field label="Image" value={data?.image} />
|
||||
<Field label="Template" value={data?.template ?? "custom"} />
|
||||
<Field label="CPU" value={`${data?.cpu} vCPU`} />
|
||||
<Field label="Memory" value={`${data?.memoryMb} MB`} />
|
||||
<Field label="PIDs limit" value={data?.pidsLimit} />
|
||||
<Field label="Network" value={data?.networkMode} />
|
||||
<Field label="Workdir" value={data?.workdir} />
|
||||
<Field label="User" value={data?.user ?? "image default"} />
|
||||
<Field
|
||||
label="Timeout"
|
||||
value={`${Math.round((data?.timeoutMs ?? 0) / 60_000)} min`}
|
||||
/>
|
||||
<Field
|
||||
label="Expires"
|
||||
value={
|
||||
data ? formatExpiresIn(data.expiresAt, data.status) : "—"
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Created"
|
||||
value={data ? <DateTooltip date={data.createdAt} /> : "—"}
|
||||
/>
|
||||
<Field
|
||||
label="Container"
|
||||
value={
|
||||
<span className="font-mono text-xs">
|
||||
{data?.containerId?.slice(0, 12) ?? "—"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{permissions?.deployment.create && (
|
||||
<SandboxConsole
|
||||
sandboxId={sandboxId}
|
||||
enabled={isRunning}
|
||||
workdir={data?.workdir ?? "/"}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SandboxPage;
|
||||
SandboxPage.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ sandboxId: string }>,
|
||||
) {
|
||||
const { params, req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user) {
|
||||
return { redirect: { permanent: false, destination: "/" } };
|
||||
}
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
if (typeof params?.sandboxId === "string") {
|
||||
try {
|
||||
await helpers.sandbox.one.fetch({ sandboxId: params.sandboxId });
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
sandboxId: params.sandboxId,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { redirect: { permanent: false, destination: "/" } };
|
||||
}
|
||||
}
|
||||
return { redirect: { permanent: false, destination: "/" } };
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import { ArrowLeft, Box } from "lucide-react";
|
||||
import type {
|
||||
GetServerSidePropsContext,
|
||||
InferGetServerSidePropsType,
|
||||
} from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { AddSandbox } from "@/components/dashboard/sandbox/add-sandbox";
|
||||
import { ShowSandboxes } from "@/components/dashboard/sandbox/show-sandboxes";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const SandboxesPage = (
|
||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||
) => {
|
||||
const { projectId, environmentId } = props;
|
||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<AdvanceBreadcrumb />
|
||||
<Head>
|
||||
<title>
|
||||
Sandboxes: {environment?.name} | {environment?.project?.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader className="flex flex-row flex-wrap justify-between items-center gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Box className="size-6 text-muted-foreground" />
|
||||
Sandboxes
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ephemeral containers for agents and automations in{" "}
|
||||
{environment?.project?.name} / {environment?.name}.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="size-4" />
|
||||
Services
|
||||
</Button>
|
||||
</Link>
|
||||
{permissions?.service.create && (
|
||||
<AddSandbox
|
||||
projectId={projectId}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-8 border-t">
|
||||
<ShowSandboxes
|
||||
projectId={projectId}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SandboxesPage;
|
||||
SandboxesPage.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ projectId: string; environmentId: string }>,
|
||||
) {
|
||||
const { params, req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user) {
|
||||
return { redirect: { permanent: false, destination: "/" } };
|
||||
}
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
if (
|
||||
typeof params?.projectId === "string" &&
|
||||
typeof params?.environmentId === "string"
|
||||
) {
|
||||
try {
|
||||
await helpers.environment.one.fetch({
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
await helpers.sandbox.list.prefetch({
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
projectId: params.projectId,
|
||||
environmentId: params.environmentId,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { redirect: { permanent: false, destination: "/" } };
|
||||
}
|
||||
}
|
||||
return { redirect: { permanent: false, destination: "/" } };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
|
||||
const Page = () => {
|
||||
return <ShowManagedServers />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext,
|
||||
) {
|
||||
if (!IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user } = await validateRequest(ctx.req);
|
||||
if (!user || user.role !== "owner") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { props: {} };
|
||||
}
|
||||
@@ -62,7 +62,6 @@ async function generateOpenAPI() {
|
||||
"rollback",
|
||||
"volumeBackups",
|
||||
"environment",
|
||||
"sandbox",
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { managedServerRouter } from "./routers/proprietary/managed-server";
|
||||
import { scimRouter } from "./routers/proprietary/scim";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
@@ -45,7 +46,6 @@ import { redirectsRouter } from "./routers/redirects";
|
||||
import { redisRouter } from "./routers/redis";
|
||||
import { registryRouter } from "./routers/registry";
|
||||
import { rollbackRouter } from "./routers/rollbacks";
|
||||
import { sandboxRouter } from "./routers/sandbox";
|
||||
import { scheduleRouter } from "./routers/schedule";
|
||||
import { securityRouter } from "./routers/security";
|
||||
import { serverRouter } from "./routers/server";
|
||||
@@ -123,7 +123,7 @@ export const appRouter = createTRPCRouter({
|
||||
patch: patchRouter,
|
||||
overview: overviewRouter,
|
||||
transfer: transferRouter,
|
||||
sandbox: sandboxRouter,
|
||||
managedServer: managedServerRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
createServer,
|
||||
IS_CLOUD,
|
||||
serverSetup,
|
||||
} from "@dokploy/server";
|
||||
import {
|
||||
apiCreateManagedServer,
|
||||
apiDeleteManagedServer,
|
||||
apiFindOneManagedServer,
|
||||
} from "@dokploy/server/db/schema/managed-server";
|
||||
import {
|
||||
createManagedServer,
|
||||
deleteManagedServer,
|
||||
findManagedServerById,
|
||||
findManagedServersByOrg,
|
||||
updateManagedServer,
|
||||
} from "@dokploy/server/services/managed-server";
|
||||
import { createSshKey } from "@dokploy/server/services/ssh-key";
|
||||
import { generateSSHKey } from "@dokploy/server/utils/filesystem/ssh";
|
||||
import {
|
||||
DOKPLOY_PLANS,
|
||||
createUpCloudServer,
|
||||
deleteUpCloudServer,
|
||||
getPublicIPv4,
|
||||
getUpCloudServer,
|
||||
getUpCloudZones,
|
||||
stopUpCloudServer,
|
||||
} from "@dokploy/server/utils/upcloud";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import { adminProcedure, createTRPCRouter } from "../../trpc";
|
||||
|
||||
|
||||
export const managedServerRouter = createTRPCRouter({
|
||||
getPlans: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
return DOKPLOY_PLANS;
|
||||
}),
|
||||
|
||||
getDataCenters: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
return getUpCloudZones();
|
||||
}),
|
||||
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) return [];
|
||||
return findManagedServersByOrg(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
one: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return record;
|
||||
}),
|
||||
|
||||
purchase: adminProcedure
|
||||
.input(apiCreateManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
const plan = DOKPLOY_PLANS.find((p) => p.id === input.plan);
|
||||
if (!plan) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan" });
|
||||
}
|
||||
|
||||
const hostname =
|
||||
`dokploy-${ctx.session.activeOrganizationId.slice(0, 8)}-${nanoid(6)}`.toLowerCase();
|
||||
|
||||
const managedRecord = await createManagedServer({
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
plan: input.plan,
|
||||
zone: input.zone,
|
||||
status: "provisioning",
|
||||
});
|
||||
|
||||
provisionManagedServer(
|
||||
managedRecord.managedServerId,
|
||||
plan.upcloudPlan,
|
||||
input.zone,
|
||||
hostname,
|
||||
ctx.session.activeOrganizationId,
|
||||
).catch(async (err) => {
|
||||
const responseBody = err?.response?.data;
|
||||
const detail =
|
||||
responseBody?.message ??
|
||||
responseBody?.error ??
|
||||
(typeof responseBody === "string" ? responseBody : null);
|
||||
const errorMessage = detail
|
||||
? `${err?.message}: ${typeof detail === "object" ? JSON.stringify(detail) : detail}`
|
||||
: (err?.message ?? "Unknown error during provisioning");
|
||||
console.error("[managed-server] provisioning failed:", errorMessage, responseBody);
|
||||
await updateManagedServer(managedRecord.managedServerId, {
|
||||
status: "error",
|
||||
errorMessage,
|
||||
});
|
||||
});
|
||||
|
||||
return managedRecord;
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(apiDeleteManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
status: "terminating",
|
||||
});
|
||||
|
||||
const upcloudUuid = record.providerVmId;
|
||||
terminateManagedServer(input.managedServerId, upcloudUuid ?? null).catch(
|
||||
(err) => console.error("[managed-server] termination failed:", err),
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
reconnect: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
if (record.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is already linked",
|
||||
});
|
||||
}
|
||||
if (!record.ipAddress) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "No IP address available to reconnect",
|
||||
});
|
||||
}
|
||||
|
||||
if (!record.sshKeyId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "No SSH key found for this managed server",
|
||||
});
|
||||
}
|
||||
|
||||
const name = record.hostname ?? record.managedServerId;
|
||||
|
||||
const serverRecord = await createServer(
|
||||
{
|
||||
name: `Managed • ${name}`,
|
||||
description: "Managed server provisioned by Dokploy Cloud",
|
||||
ipAddress: record.ipAddress,
|
||||
port: 22,
|
||||
username: "root",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
sshKeyId: record.sshKeyId,
|
||||
},
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
serverId: serverRecord.serverId,
|
||||
status: "ready",
|
||||
});
|
||||
|
||||
return findManagedServerById(input.managedServerId);
|
||||
}),
|
||||
|
||||
syncStatus: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const upcloudUuid = record.providerVmId;
|
||||
if (!upcloudUuid) return record;
|
||||
|
||||
const vm = await getUpCloudServer(upcloudUuid);
|
||||
const ipAddress = getPublicIPv4(vm) ?? record.ipAddress ?? undefined;
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
ipAddress,
|
||||
hostname: vm.hostname ?? undefined,
|
||||
status:
|
||||
vm.state === "started"
|
||||
? record.serverId
|
||||
? "ready"
|
||||
: "configuring"
|
||||
: record.status,
|
||||
});
|
||||
|
||||
return findManagedServerById(input.managedServerId);
|
||||
}),
|
||||
});
|
||||
|
||||
async function provisionManagedServer(
|
||||
managedServerId: string,
|
||||
upcloudPlan: string,
|
||||
zone: string,
|
||||
hostname: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const { publicKey, privateKey } = await generateSSHKey("rsa");
|
||||
|
||||
const vm = await createUpCloudServer({
|
||||
hostname,
|
||||
upcloudPlan,
|
||||
zone,
|
||||
sshKey: publicKey,
|
||||
});
|
||||
|
||||
await updateManagedServer(managedServerId, {
|
||||
providerVmId: vm.uuid,
|
||||
hostname: vm.hostname,
|
||||
status: "configuring",
|
||||
});
|
||||
|
||||
await waitForServerStarted(vm.uuid, managedServerId);
|
||||
|
||||
const finalVm = await getUpCloudServer(vm.uuid);
|
||||
const finalIp = getPublicIPv4(finalVm);
|
||||
|
||||
if (!finalIp) {
|
||||
throw new Error("VM is started but has no public IPv4 address");
|
||||
}
|
||||
|
||||
const sshKey = await createSshKey({
|
||||
name: hostname,
|
||||
publicKey,
|
||||
privateKey,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
if (!sshKey) throw new Error("Failed to create SSH key");
|
||||
|
||||
const serverRecord = await createServer(
|
||||
{
|
||||
name: `Managed • ${hostname}`,
|
||||
description: "Managed server provisioned by Dokploy Cloud",
|
||||
ipAddress: finalIp,
|
||||
port: 22,
|
||||
username: "root",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
sshKeyId: sshKey.sshKeyId,
|
||||
},
|
||||
organizationId,
|
||||
);
|
||||
|
||||
await updateManagedServer(managedServerId, {
|
||||
serverId: serverRecord.serverId,
|
||||
sshKeyId: sshKey.sshKeyId,
|
||||
ipAddress: finalIp,
|
||||
});
|
||||
|
||||
await serverSetup(serverRecord.serverId);
|
||||
await updateManagedServer(managedServerId, { status: "ready" });
|
||||
}
|
||||
|
||||
async function waitForServerStarted(
|
||||
uuid: string,
|
||||
_managedServerId: string,
|
||||
maxAttempts = 30,
|
||||
intervalMs = 10_000,
|
||||
) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
const vm = await getUpCloudServer(uuid);
|
||||
if (vm.state === "started") return;
|
||||
if (vm.state === "error") throw new Error("VM entered error state");
|
||||
}
|
||||
throw new Error("Timed out waiting for VM to start");
|
||||
}
|
||||
|
||||
async function waitForServerStopped(
|
||||
uuid: string,
|
||||
maxAttempts = 18,
|
||||
intervalMs = 10_000,
|
||||
) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
const vm = await getUpCloudServer(uuid);
|
||||
if (vm.state === "stopped") return;
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateManagedServer(
|
||||
managedServerId: string,
|
||||
upcloudUuid: string | null,
|
||||
) {
|
||||
if (upcloudUuid) {
|
||||
await stopUpCloudServer(upcloudUuid);
|
||||
await waitForServerStopped(upcloudUuid);
|
||||
await deleteUpCloudServer(upcloudUuid);
|
||||
}
|
||||
await deleteManagedServer(managedServerId);
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import {
|
||||
createSandbox,
|
||||
execInSandbox,
|
||||
findEnvironmentById,
|
||||
findProjectById,
|
||||
findSandboxById,
|
||||
findSandboxesByEnvironmentId,
|
||||
findSandboxesByProjectId,
|
||||
getAccessibleServerIds,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
killSandbox,
|
||||
listSandboxFiles,
|
||||
readSandboxFile,
|
||||
removeSandbox,
|
||||
SANDBOX_TEMPLATES,
|
||||
setSandboxTimeout,
|
||||
writeSandboxFile,
|
||||
} from "@dokploy/server";
|
||||
import {
|
||||
addNewService,
|
||||
checkEnvironmentAccess,
|
||||
checkServiceAccess,
|
||||
checkServicePermissionAndAccess,
|
||||
findMemberByUserId,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
apiCreateSandbox,
|
||||
apiExecSandbox,
|
||||
apiFindOneSandbox,
|
||||
apiListFilesSandbox,
|
||||
apiListSandboxes,
|
||||
apiReadFileSandbox,
|
||||
apiSetTimeoutSandbox,
|
||||
apiWriteFileSandbox,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
type Ctx = {
|
||||
user: { id: string; role: string };
|
||||
session: { activeOrganizationId: string };
|
||||
};
|
||||
|
||||
const findAccessibleSandbox = async (
|
||||
ctx: Ctx,
|
||||
sandboxId: string,
|
||||
mode: "read" | "use" | "delete",
|
||||
) => {
|
||||
if (mode === "read") {
|
||||
await checkServiceAccess(ctx, sandboxId, "read");
|
||||
} else if (mode === "delete") {
|
||||
await checkServiceAccess(ctx, sandboxId, "delete");
|
||||
} else {
|
||||
await checkServicePermissionAndAccess(ctx, sandboxId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
}
|
||||
const sandbox = await findSandboxById(sandboxId);
|
||||
if (
|
||||
sandbox.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this sandbox",
|
||||
});
|
||||
}
|
||||
return sandbox;
|
||||
};
|
||||
|
||||
const stripSecrets = <T extends { envVars: string | null }>(sandbox: T) => {
|
||||
const { envVars: _envVars, ...rest } = sandbox;
|
||||
return rest;
|
||||
};
|
||||
|
||||
export const sandboxRouter = createTRPCRouter({
|
||||
templates: protectedProcedure.query(() =>
|
||||
Object.entries(SANDBOX_TEMPLATES).map(([name, template]) => ({
|
||||
name,
|
||||
...template,
|
||||
})),
|
||||
),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(apiCreateSandbox)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const environment = await findEnvironmentById(input.environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
if (project.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this project",
|
||||
});
|
||||
}
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
if (
|
||||
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||
!input.serverId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a sandbox",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.serverId) {
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
if (!accessibleIds.has(input.serverId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sandbox = await createSandbox(input);
|
||||
await addNewService(ctx, sandbox.sandboxId);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "sandbox",
|
||||
resourceId: sandbox.sandboxId,
|
||||
resourceName: sandbox.name,
|
||||
});
|
||||
return stripSecrets(sandbox);
|
||||
}),
|
||||
|
||||
one: protectedProcedure
|
||||
.input(apiFindOneSandbox)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
|
||||
return stripSecrets(sandbox);
|
||||
}),
|
||||
|
||||
list: protectedProcedure
|
||||
.input(apiListSandboxes)
|
||||
.query(async ({ input, ctx }) => {
|
||||
let sandboxes: Awaited<ReturnType<typeof findSandboxesByEnvironmentId>>;
|
||||
if (input.environmentId) {
|
||||
await checkEnvironmentAccess(ctx, input.environmentId, "read");
|
||||
const environment = await findEnvironmentById(input.environmentId);
|
||||
if (
|
||||
environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
sandboxes = await findSandboxesByEnvironmentId(input.environmentId);
|
||||
} else {
|
||||
const project = await findProjectById(input.projectId ?? "");
|
||||
if (project.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
|
||||
const member = await findMemberByUserId(
|
||||
ctx.user.id,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (!member.accessedProjects.includes(project.projectId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
}
|
||||
sandboxes = await findSandboxesByProjectId(project.projectId);
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
|
||||
const member = await findMemberByUserId(
|
||||
ctx.user.id,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
sandboxes = sandboxes.filter((sandbox) =>
|
||||
member.accessedServices.includes(sandbox.sandboxId),
|
||||
);
|
||||
}
|
||||
return sandboxes.map(stripSecrets);
|
||||
}),
|
||||
|
||||
exec: protectedProcedure
|
||||
.input(apiExecSandbox)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
|
||||
const result = await execInSandbox(sandbox, {
|
||||
cmd: input.cmd,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutMs: input.timeoutMs,
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
truncated: result.truncated,
|
||||
};
|
||||
}),
|
||||
|
||||
writeFile: protectedProcedure
|
||||
.input(apiWriteFileSandbox)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
|
||||
return writeSandboxFile(sandbox, {
|
||||
path: input.path,
|
||||
content: Buffer.from(input.content, input.encoding),
|
||||
mode: input.mode,
|
||||
});
|
||||
}),
|
||||
|
||||
readFile: protectedProcedure
|
||||
.input(apiReadFileSandbox)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
|
||||
const file = await readSandboxFile(sandbox, input.path);
|
||||
return {
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
encoding: input.encoding,
|
||||
content: file.content.toString(input.encoding),
|
||||
};
|
||||
}),
|
||||
|
||||
listFiles: protectedProcedure
|
||||
.input(apiListFilesSandbox)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
|
||||
return listSandboxFiles(sandbox, input.path);
|
||||
}),
|
||||
|
||||
setTimeout: protectedProcedure
|
||||
.input(apiSetTimeoutSandbox)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
|
||||
const updated = await setSandboxTimeout(sandbox, input.timeoutMs);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "sandbox",
|
||||
resourceId: sandbox.sandboxId,
|
||||
resourceName: sandbox.name,
|
||||
metadata: { timeoutMs: input.timeoutMs },
|
||||
});
|
||||
return updated ? stripSecrets(updated) : null;
|
||||
}),
|
||||
|
||||
kill: protectedProcedure
|
||||
.input(apiFindOneSandbox)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
|
||||
const killed = await killSandbox(sandbox.sandboxId);
|
||||
await audit(ctx, {
|
||||
action: "stop",
|
||||
resourceType: "sandbox",
|
||||
resourceId: sandbox.sandboxId,
|
||||
resourceName: sandbox.name,
|
||||
});
|
||||
return killed ? stripSecrets(killed) : null;
|
||||
}),
|
||||
|
||||
remove: protectedProcedure
|
||||
.input(apiFindOneSandbox)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const sandbox = await findAccessibleSandbox(
|
||||
ctx,
|
||||
input.sandboxId,
|
||||
"delete",
|
||||
);
|
||||
await removeSandbox(sandbox.sandboxId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "sandbox",
|
||||
resourceId: sandbox.sandboxId,
|
||||
resourceName: sandbox.name,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
@@ -707,7 +707,6 @@ export const settingsRouter = createTRPCRouter({
|
||||
"licenseKey",
|
||||
"organization",
|
||||
"previewDeployment",
|
||||
"sandbox",
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateSSHKeyById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { managedServer } from "@dokploy/server/db/schema/managed-server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import {
|
||||
@@ -57,6 +58,16 @@ export const sshRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const linkedManagedServer = await db.query.managedServer.findFirst({
|
||||
where: eq(managedServer.sshKeyId, input.sshKeyId),
|
||||
});
|
||||
if (linkedManagedServer) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This SSH key is used by a managed server and cannot be deleted.",
|
||||
});
|
||||
}
|
||||
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "sshKey",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getCurrentPlan as getCurrentPlanForOrganization,
|
||||
getStripeClient,
|
||||
TRIAL_DURATION_DAYS,
|
||||
TRIAL_SERVER_LIMIT,
|
||||
TRIAL_SERVER_LIMITS,
|
||||
} from "@/server/utils/billing";
|
||||
import {
|
||||
type BillingTier,
|
||||
@@ -45,72 +45,82 @@ export const stripeRouter = createTRPCRouter({
|
||||
return getBillingStatus(ctx.user.ownerId);
|
||||
}),
|
||||
|
||||
startFreeTrial: adminProcedure.mutation(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This feature is only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
if (!HOBBY_PRICE_MONTHLY_ID) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Trials are not configured",
|
||||
});
|
||||
}
|
||||
|
||||
const owner = await findUserById(ctx.user.ownerId);
|
||||
const billingStatus = await getBillingStatus(owner.id);
|
||||
|
||||
if (billingStatus.hasActiveAccess) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You already have an active plan or trial",
|
||||
});
|
||||
}
|
||||
|
||||
if (billingStatus.hasUsedTrial) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You have already used your free trial",
|
||||
});
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
let stripeCustomerId = owner.stripeCustomerId;
|
||||
if (stripeCustomerId) {
|
||||
const customer = await stripe.customers.retrieve(stripeCustomerId);
|
||||
if (customer.deleted) {
|
||||
stripeCustomerId = null;
|
||||
startFreeTrial: adminProcedure
|
||||
.input(z.object({ tier: z.enum(["hobby", "startup"]) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This feature is only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!stripeCustomerId) {
|
||||
const customer = await stripe.customers.create({ email: owner.email });
|
||||
stripeCustomerId = customer.id;
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.create({
|
||||
customer: stripeCustomerId,
|
||||
items: [{ price: HOBBY_PRICE_MONTHLY_ID, quantity: 1 }],
|
||||
trial_period_days: TRIAL_DURATION_DAYS,
|
||||
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
|
||||
metadata: { source: "onboarding_trial", adminId: owner.id },
|
||||
});
|
||||
const trialPriceId =
|
||||
input.tier === "startup"
|
||||
? STARTUP_BASE_PRICE_MONTHLY_ID
|
||||
: HOBBY_PRICE_MONTHLY_ID;
|
||||
if (!trialPriceId) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Trials are not configured",
|
||||
});
|
||||
}
|
||||
|
||||
await updateUser(owner.id, {
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
serversQuantity: TRIAL_SERVER_LIMIT,
|
||||
});
|
||||
const owner = await findUserById(ctx.user.ownerId);
|
||||
const billingStatus = await getBillingStatus(owner.id);
|
||||
|
||||
return {
|
||||
trialEndsAt: subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
if (billingStatus.hasActiveAccess) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You already have an active plan or trial",
|
||||
});
|
||||
}
|
||||
|
||||
if (billingStatus.hasUsedTrial) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You have already used your free trial",
|
||||
});
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
let stripeCustomerId = owner.stripeCustomerId;
|
||||
if (stripeCustomerId) {
|
||||
const customer = await stripe.customers.retrieve(stripeCustomerId);
|
||||
if (customer.deleted) {
|
||||
stripeCustomerId = null;
|
||||
}
|
||||
}
|
||||
if (!stripeCustomerId) {
|
||||
const customer = await stripe.customers.create({ email: owner.email });
|
||||
stripeCustomerId = customer.id;
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.create({
|
||||
customer: stripeCustomerId,
|
||||
items: [{ price: trialPriceId, quantity: 1 }],
|
||||
trial_period_days: TRIAL_DURATION_DAYS,
|
||||
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
|
||||
metadata: {
|
||||
source: "onboarding_trial",
|
||||
adminId: owner.id,
|
||||
tier: input.tier,
|
||||
},
|
||||
});
|
||||
|
||||
await updateUser(owner.id, {
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
serversQuantity: TRIAL_SERVER_LIMITS[input.tier],
|
||||
});
|
||||
|
||||
return {
|
||||
trialEndsAt: subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
|
||||
getProducts: adminProcedure.query(async ({ ctx }) => {
|
||||
const user = await findUserById(ctx.user.ownerId);
|
||||
@@ -343,9 +353,15 @@ export const stripeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const isTrialing = subscription.status === "trialing";
|
||||
// Trials are capped at the plan's included servers; paid plans can scale.
|
||||
const serverQuantity = isTrialing
|
||||
? TRIAL_SERVER_LIMITS[input.tier]
|
||||
: input.serverQuantity;
|
||||
|
||||
const newItems = getStripeItems(
|
||||
input.tier as BillingTier,
|
||||
input.serverQuantity,
|
||||
serverQuantity,
|
||||
input.isAnnual,
|
||||
);
|
||||
const currentItems = subscription.items.data;
|
||||
@@ -371,7 +387,10 @@ export const stripeRouter = createTRPCRouter({
|
||||
|
||||
await stripe.subscriptions.update(owner.stripeSubscriptionId, {
|
||||
items: updateItems,
|
||||
proration_behavior: "create_prorations",
|
||||
proration_behavior: isTrialing ? "none" : "create_prorations",
|
||||
...(isTrialing && subscription.trial_end
|
||||
? { trial_end: subscription.trial_end }
|
||||
: {}),
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
initCronJobs,
|
||||
initEnterpriseBackupCronJobs,
|
||||
initializeNetwork,
|
||||
initSandboxReaper,
|
||||
initSchedules,
|
||||
initVolumeBackupsCronJobs,
|
||||
sendDokployRestartNotifications,
|
||||
@@ -22,7 +21,6 @@ import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-contai
|
||||
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
|
||||
import { setupDrawerLogsWebSocketServer } from "./wss/drawer-logs";
|
||||
import { setupDeploymentLogsWebSocketServer } from "./wss/listen-deployment";
|
||||
import { setupSandboxExecWebSocketServer } from "./wss/sandbox-exec";
|
||||
import { setupTerminalWebSocketServer } from "./wss/terminal";
|
||||
|
||||
config({ path: ".env" });
|
||||
@@ -54,7 +52,6 @@ void app.prepare().then(async () => {
|
||||
setupDockerContainerLogsWebSocketServer(server);
|
||||
setupDockerContainerTerminalWebSocketServer(server);
|
||||
setupTerminalWebSocketServer(server);
|
||||
setupSandboxExecWebSocketServer(server);
|
||||
if (!IS_CLOUD) {
|
||||
setupDockerStatsMonitoringSocketServer(server);
|
||||
}
|
||||
@@ -71,7 +68,6 @@ void app.prepare().then(async () => {
|
||||
await sendDokployRestartNotifications();
|
||||
}
|
||||
await initEnterpriseBackupCronJobs();
|
||||
void initSandboxReaper();
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
console.log("Starting Deployment Worker");
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getStripeClient = () =>
|
||||
apiVersion: "2024-09-30.acacia",
|
||||
});
|
||||
|
||||
const planFromPriceIds = (priceIds: string[]): BillingPlan | null => {
|
||||
export const planFromPriceIds = (priceIds: string[]): BillingPlan | null => {
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) =>
|
||||
@@ -78,7 +78,11 @@ export const getCurrentPlan = async (
|
||||
};
|
||||
|
||||
export const TRIAL_DURATION_DAYS = 7;
|
||||
export const TRIAL_SERVER_LIMIT = 1;
|
||||
export type TrialTier = "hobby" | "startup";
|
||||
export const TRIAL_SERVER_LIMITS: Record<TrialTier, number> = {
|
||||
hobby: 1,
|
||||
startup: 3,
|
||||
};
|
||||
|
||||
export interface BillingStatus {
|
||||
plan: BillingPlan | null;
|
||||
@@ -87,6 +91,8 @@ export interface BillingStatus {
|
||||
trialDaysRemaining: number | null;
|
||||
hasUsedTrial: boolean;
|
||||
hasActiveAccess: boolean;
|
||||
hasPaymentMethod: boolean;
|
||||
isAnnual: boolean;
|
||||
}
|
||||
|
||||
export const getBillingStatus = async (
|
||||
@@ -100,6 +106,8 @@ export const getBillingStatus = async (
|
||||
trialDaysRemaining: null,
|
||||
hasUsedTrial: false,
|
||||
hasActiveAccess: true,
|
||||
hasPaymentMethod: true,
|
||||
isAnnual: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,6 +120,8 @@ export const getBillingStatus = async (
|
||||
trialDaysRemaining: null,
|
||||
hasUsedTrial: false,
|
||||
hasActiveAccess: false,
|
||||
hasPaymentMethod: false,
|
||||
isAnnual: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,7 +129,7 @@ export const getBillingStatus = async (
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: owner.stripeCustomerId,
|
||||
status: "all",
|
||||
expand: ["data.items.data.price"],
|
||||
expand: ["data.items.data.price", "data.customer"],
|
||||
});
|
||||
|
||||
const relevantSubs = subscriptions.data.filter(
|
||||
@@ -133,6 +143,15 @@ export const getBillingStatus = async (
|
||||
const trialingSub = subscriptions.data.find(
|
||||
(sub) => sub.status === "trialing",
|
||||
);
|
||||
|
||||
const currentSub = trialingSub ?? relevantSubs[0];
|
||||
const customer = currentSub?.customer as Stripe.Customer | undefined;
|
||||
const hasPaymentMethod =
|
||||
!!currentSub?.default_payment_method ||
|
||||
!!customer?.invoice_settings?.default_payment_method;
|
||||
const isAnnual =
|
||||
(currentSub?.items.data[0]?.price as Stripe.Price | undefined)?.recurring
|
||||
?.interval === "year";
|
||||
const trialEndsAt = trialingSub?.trial_end
|
||||
? new Date(trialingSub.trial_end * 1000)
|
||||
: null;
|
||||
@@ -148,7 +167,9 @@ export const getBillingStatus = async (
|
||||
isOnTrial: !!trialingSub,
|
||||
trialEndsAt,
|
||||
trialDaysRemaining,
|
||||
hasPaymentMethod,
|
||||
hasUsedTrial: subscriptions.data.length > 0,
|
||||
hasActiveAccess: plan !== null || !!trialingSub,
|
||||
isAnnual,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import TrialExpiringEmail from "@dokploy/server/emails/emails/trial-expiring";
|
||||
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
|
||||
import { render } from "@react-email/components";
|
||||
import { format } from "date-fns";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type Stripe from "stripe";
|
||||
import { user } from "@/server/db/schema";
|
||||
import { planFromPriceIds } from "@/server/utils/billing";
|
||||
import { WEBSITE_URL } from "@/server/utils/stripe";
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
hobby: "Hobby",
|
||||
startup: "Startup",
|
||||
legacy: "Legacy",
|
||||
};
|
||||
|
||||
const customerIdOf = (subscription: Stripe.Subscription) =>
|
||||
typeof subscription.customer === "string"
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
const hasPaymentMethod = async (
|
||||
stripe: Stripe,
|
||||
subscription: Stripe.Subscription,
|
||||
) => {
|
||||
if (subscription.default_payment_method) return true;
|
||||
|
||||
const customer = await stripe.customers.retrieve(customerIdOf(subscription));
|
||||
if (customer.deleted) return true;
|
||||
return !!customer.invoice_settings?.default_payment_method;
|
||||
};
|
||||
|
||||
export const sendTrialExpiringEmail = async (
|
||||
stripe: Stripe,
|
||||
subscription: Stripe.Subscription,
|
||||
// Stripe retries a failing webhook for days, so the countdown is measured
|
||||
// from when Stripe emitted the event rather than from the current time.
|
||||
notifiedAtSeconds: number,
|
||||
) => {
|
||||
if (!subscription.trial_end) return false;
|
||||
if (await hasPaymentMethod(stripe, subscription)) return false;
|
||||
|
||||
const owner = await db.query.user.findFirst({
|
||||
where: eq(user.stripeCustomerId, customerIdOf(subscription)),
|
||||
});
|
||||
if (!owner || owner.isEnterpriseCloud) return false;
|
||||
|
||||
const trialEndsAt = new Date(subscription.trial_end * 1000);
|
||||
const daysRemaining = Math.max(
|
||||
1,
|
||||
Math.ceil((subscription.trial_end - notifiedAtSeconds) / (60 * 60 * 24)),
|
||||
);
|
||||
const plan = planFromPriceIds(
|
||||
subscription.items.data.map((item) => (item.price as Stripe.Price).id),
|
||||
);
|
||||
|
||||
const htmlContent = await render(
|
||||
TrialExpiringEmail({
|
||||
userName: owner.firstName || "User",
|
||||
planName: plan ? (PLAN_LABELS[plan] ?? plan) : "Trial",
|
||||
daysRemaining,
|
||||
endsOn: format(trialEndsAt, "MMM dd, yyyy"),
|
||||
billingUrl: `${WEBSITE_URL}/dashboard/settings/billing`,
|
||||
}),
|
||||
);
|
||||
|
||||
await sendEmail({
|
||||
email: owner.email,
|
||||
subject:
|
||||
daysRemaining === 1
|
||||
? "Your Dokploy trial ends tomorrow"
|
||||
: `Your Dokploy trial ends in ${daysRemaining} days`,
|
||||
text: htmlContent,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
import type http from "node:http";
|
||||
import {
|
||||
execInSandbox,
|
||||
findSandboxById,
|
||||
validateRequest,
|
||||
} from "@dokploy/server";
|
||||
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
||||
import { type RawData, type WebSocket, WebSocketServer } from "ws";
|
||||
import { z } from "zod";
|
||||
import { apiExecSandbox, SANDBOX_DEFAULTS } from "@/server/db/schema";
|
||||
|
||||
const execMessage = apiExecSandbox.omit({ sandboxId: true }).extend({
|
||||
type: z.literal("exec"),
|
||||
});
|
||||
|
||||
type ServerMessage =
|
||||
| { type: "ready"; sandboxId: string }
|
||||
| { type: "stdout"; data: string }
|
||||
| { type: "stderr"; data: string }
|
||||
| { type: "exit"; exitCode: number; timedOut: boolean }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
const send = (ws: WebSocket, message: ServerMessage) => {
|
||||
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message));
|
||||
};
|
||||
|
||||
export const setupSandboxExecWebSocketServer = (
|
||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||
) => {
|
||||
const wss = new WebSocketServer({ noServer: true, path: "/sandbox-exec" });
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const { pathname } = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
if (pathname === "/sandbox-exec") {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
wss.on("connection", async (ws, req) => {
|
||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
const sandboxId = url.searchParams.get("sandboxId");
|
||||
if (!sandboxId) {
|
||||
ws.close(4000, "sandboxId not provided");
|
||||
return;
|
||||
}
|
||||
|
||||
let running = false;
|
||||
const handle = async (raw: RawData) => {
|
||||
let parsed: z.infer<typeof execMessage>;
|
||||
try {
|
||||
parsed = execMessage.parse(JSON.parse(raw.toString()));
|
||||
} catch (error) {
|
||||
send(ws, {
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : "Invalid message",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (running) {
|
||||
send(ws, { type: "error", message: "A command is already running" });
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
try {
|
||||
const sandbox = await findSandboxById(sandboxId);
|
||||
const result = await execInSandbox(sandbox, {
|
||||
cmd: parsed.cmd,
|
||||
cwd: parsed.cwd,
|
||||
env: parsed.env,
|
||||
timeoutMs: parsed.timeoutMs ?? SANDBOX_DEFAULTS.execTimeoutMs,
|
||||
onStdout: (data) => send(ws, { type: "stdout", data }),
|
||||
onStderr: (data) => send(ws, { type: "stderr", data }),
|
||||
});
|
||||
send(ws, {
|
||||
type: "exit",
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
});
|
||||
} catch (error) {
|
||||
send(ws, {
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Clients may send as soon as the socket opens, before authorization
|
||||
// finishes; queue those messages instead of dropping them.
|
||||
let authorized = false;
|
||||
const pending: RawData[] = [];
|
||||
ws.on("message", (raw) => {
|
||||
if (!authorized) {
|
||||
pending.push(raw);
|
||||
return;
|
||||
}
|
||||
void handle(raw);
|
||||
});
|
||||
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || !session?.activeOrganizationId) {
|
||||
ws.close(4001, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
const ctx = {
|
||||
user: { id: user.id },
|
||||
session: { activeOrganizationId: session.activeOrganizationId },
|
||||
};
|
||||
|
||||
try {
|
||||
await checkServicePermissionAndAccess(ctx, sandboxId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
const sandbox = await findSandboxById(sandboxId);
|
||||
if (
|
||||
sandbox.environment.project.organizationId !==
|
||||
session.activeOrganizationId
|
||||
) {
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
|
||||
authorized = true;
|
||||
send(ws, { type: "ready", sandboxId });
|
||||
for (const raw of pending.splice(0)) {
|
||||
void handle(raw);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,218 +0,0 @@
|
||||
# Sandboxes
|
||||
|
||||
A sandbox is an ephemeral, resource-limited Docker container that lives inside a
|
||||
Dokploy environment and is driven entirely through the API: create it, run
|
||||
commands, read and write files, and kill it. It is meant for AI agents and
|
||||
backends (SDK / REST / MCP), not for hosting services: it has no domain, no
|
||||
published ports and no persistent volumes.
|
||||
|
||||
Sandboxes run on the Dokploy server or on any remote server registered in
|
||||
Dokploy (the container is created through the existing SSH Docker connection).
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```
|
||||
create ─► running ─► killed
|
||||
│ ▲
|
||||
└──── error ────────────┘ (reaper / reconcile / failed command)
|
||||
```
|
||||
|
||||
- `create` pulls the image if missing, creates the container, starts it and
|
||||
returns the record with `status: "running"` and `expiresAt`.
|
||||
- Every operation refreshes `lastActivityAt` and pushes `expiresAt` to
|
||||
`now + timeoutMs`. An idle sandbox is killed by the reaper (runs every 15 s)
|
||||
once `expiresAt` passes.
|
||||
- `kill` stops the container (2 s grace, then SIGKILL) and removes it. The row
|
||||
is kept with `status: "killed"` for history; `remove` deletes the row.
|
||||
- On startup Dokploy reconciles every server: containers labelled
|
||||
`dokploy.sandbox=true` without a running row are removed, and rows marked
|
||||
running whose container is gone are set to `error`.
|
||||
|
||||
## Templates
|
||||
|
||||
| template | image (placeholder until `dokploy/sandbox-*` is published) | user | workdir |
|
||||
| -------- | ---------------------------------------------------------- | --------- | ------------ |
|
||||
| `base` | `ubuntu:24.04` | 1000:1000 | `/home/user` |
|
||||
| `python` | `python:3.12-slim` | 1000:1000 | `/home/user` |
|
||||
| `node` | `node:22-slim` | 1000:1000 | `/home/user` |
|
||||
|
||||
A custom `image` can be used instead of a template. It must contain a POSIX
|
||||
`sh` and `tail`; it runs as the image's default user.
|
||||
|
||||
## REST API
|
||||
|
||||
All procedures of the `sandbox` tRPC router are exposed through the OpenAPI
|
||||
layer at `/api/sandbox.<procedure>` (queries are `GET` with query-string
|
||||
parameters, mutations are `POST` with a JSON body). Authenticate with an API
|
||||
key from *Settings → API Keys*:
|
||||
|
||||
```bash
|
||||
export DOKPLOY_URL=https://dokploy.example.com
|
||||
export DOKPLOY_KEY=xxxxxxxx
|
||||
```
|
||||
|
||||
### Create
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$DOKPLOY_URL/api/sandbox.create" \
|
||||
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
|
||||
-d '{
|
||||
"environmentId": "ENV_ID",
|
||||
"template": "python",
|
||||
"name": "agent-run-42",
|
||||
"cpu": 1,
|
||||
"memoryMb": 512,
|
||||
"timeoutMs": 300000,
|
||||
"networkMode": "isolated",
|
||||
"envVars": "OPENAI_API_KEY=sk-..."
|
||||
}'
|
||||
```
|
||||
|
||||
Optional fields: `image` (instead of `template`), `serverId` (omit for the
|
||||
Dokploy server), `pidsLimit`, `workdir`. The response is the sandbox record
|
||||
(`sandboxId`, `status`, `containerId`, `expiresAt`, …). `envVars` is never
|
||||
returned.
|
||||
|
||||
### Run a command
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$DOKPLOY_URL/api/sandbox.exec" \
|
||||
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
|
||||
-d '{"sandboxId": "SANDBOX_ID", "cmd": "python3 -c \"print(1+1)\"", "timeoutMs": 60000}'
|
||||
# {"stdout":"2\n","stderr":"","exitCode":0,"timedOut":false,"truncated":false}
|
||||
```
|
||||
|
||||
`cmd` runs through `sh -c` in `cwd` (defaults to the sandbox workdir) with the
|
||||
optional `env` object merged in. When the per-command `timeoutMs` (default
|
||||
60 s, max 10 min) elapses the process tree is killed and `exitCode` is `124`.
|
||||
`stdout`/`stderr` are capped at 1 MiB each (`truncated: true` when cut).
|
||||
|
||||
### Write and read files
|
||||
|
||||
```bash
|
||||
# utf8 content
|
||||
curl -s -X POST "$DOKPLOY_URL/api/sandbox.writeFile" \
|
||||
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
|
||||
-d '{"sandboxId": "SANDBOX_ID", "path": "/home/user/app/main.py", "content": "print(\"hi\")\n"}'
|
||||
|
||||
# binary content
|
||||
curl -s -X POST "$DOKPLOY_URL/api/sandbox.writeFile" \
|
||||
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
|
||||
-d "{\"sandboxId\": \"SANDBOX_ID\", \"path\": \"/home/user/data.bin\", \"encoding\": \"base64\", \"content\": \"$(base64 < data.bin)\"}"
|
||||
|
||||
curl -s "$DOKPLOY_URL/api/sandbox.readFile?sandboxId=SANDBOX_ID&path=/home/user/app/main.py" \
|
||||
-H "x-api-key: $DOKPLOY_KEY"
|
||||
# {"path":"/home/user/app/main.py","size":12,"encoding":"utf8","content":"print(\"hi\")\n"}
|
||||
|
||||
curl -s "$DOKPLOY_URL/api/sandbox.listFiles?sandboxId=SANDBOX_ID&path=/home/user/app" \
|
||||
-H "x-api-key: $DOKPLOY_KEY"
|
||||
# {"path":"/home/user/app","entries":[{"name":"main.py","type":"file","size":12,"mode":"644","modifiedAt":"..."}]}
|
||||
```
|
||||
|
||||
Parent directories are created with `mkdir -p`; files are owned by the sandbox
|
||||
user. Files are capped at 10 MiB in both directions. `listFiles` falls back to
|
||||
a name-only listing (`size`/`mode`/`modifiedAt` are `null`) on images whose
|
||||
`find` has no `-printf` (busybox).
|
||||
|
||||
### Timeout, list, kill
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$DOKPLOY_URL/api/sandbox.setTimeout" \
|
||||
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
|
||||
-d '{"sandboxId": "SANDBOX_ID", "timeoutMs": 900000}'
|
||||
|
||||
curl -s "$DOKPLOY_URL/api/sandbox.list?environmentId=ENV_ID" -H "x-api-key: $DOKPLOY_KEY"
|
||||
curl -s "$DOKPLOY_URL/api/sandbox.list?projectId=PROJECT_ID" -H "x-api-key: $DOKPLOY_KEY"
|
||||
curl -s "$DOKPLOY_URL/api/sandbox.one?sandboxId=SANDBOX_ID" -H "x-api-key: $DOKPLOY_KEY"
|
||||
|
||||
curl -s -X POST "$DOKPLOY_URL/api/sandbox.kill" \
|
||||
-H "x-api-key: $DOKPLOY_KEY" -H "content-type: application/json" \
|
||||
-d '{"sandboxId": "SANDBOX_ID"}'
|
||||
```
|
||||
|
||||
`sandbox.remove` deletes the record (killing the container first if needed).
|
||||
`sandbox.templates` lists the built-in templates.
|
||||
|
||||
### MCP
|
||||
|
||||
The MCP server is generated from `openapi.json`, so the tools are named after
|
||||
the operation ids: `sandbox-create`, `sandbox-exec`, `sandbox-writeFile`,
|
||||
`sandbox-readFile`, `sandbox-listFiles`, `sandbox-setTimeout`, `sandbox-kill`,
|
||||
`sandbox-remove`, `sandbox-list`, `sandbox-one`, `sandbox-templates`.
|
||||
|
||||
## Streaming exec (WebSocket)
|
||||
|
||||
Long-running commands can stream their output over the WebSocket endpoint
|
||||
`/sandbox-exec?sandboxId=SANDBOX_ID` (same origin as the dashboard, session
|
||||
cookie or `x-api-key` header). Messages are JSON, one command at a time per
|
||||
connection:
|
||||
|
||||
| direction | message |
|
||||
| --------- | ------- |
|
||||
| server → client | `{"type":"ready","sandboxId":"..."}` once authorized |
|
||||
| client → server | `{"type":"exec","cmd":"...","cwd"?:"...","env"?:{...},"timeoutMs"?:60000}` |
|
||||
| server → client | `{"type":"stdout","data":"..."}` / `{"type":"stderr","data":"..."}` chunks |
|
||||
| server → client | `{"type":"exit","exitCode":0,"timedOut":false}` when the command finishes |
|
||||
| server → client | `{"type":"error","message":"..."}` on validation / runtime errors |
|
||||
|
||||
The dashboard console on the sandbox page uses this protocol.
|
||||
|
||||
## Permissions
|
||||
|
||||
- `create` requires `service:create` on the project (same as creating any
|
||||
service); the creator is granted access to the new sandbox.
|
||||
- `one`, `list`, `readFile`, `listFiles` require `service:read` on the sandbox.
|
||||
- `exec`, `writeFile`, `setTimeout`, `kill` and the WebSocket require
|
||||
`deployment:create` on the sandbox (the same permission used to start/stop
|
||||
services).
|
||||
- `remove` requires `service:delete`.
|
||||
- Every operation also checks the sandbox belongs to the caller's active
|
||||
organization and, on creation, that the caller can access the target server.
|
||||
|
||||
## Security model
|
||||
|
||||
Each sandbox container is created with:
|
||||
|
||||
- `CapDrop: ["ALL"]` and `SecurityOpt: ["no-new-privileges"]`.
|
||||
- `Init: true` (docker-init/tini as PID 1) so processes orphaned by a killed
|
||||
command are reaped instead of lingering as zombies.
|
||||
- CPU (`NanoCpus`), memory (`Memory` = `MemorySwap`, so no swap) and
|
||||
`PidsLimit` quotas; `RestartPolicy: no`; JSON log capped at 10 MiB.
|
||||
- A non-root user for the built-in templates (`1000:1000`); the workdir is
|
||||
created and owned by that user through the Docker archive API, so no
|
||||
capability is needed inside the container.
|
||||
- A dedicated bridge network per server: `dokploy-sandboxes-isolated`
|
||||
(`internal: true`, no egress) for `networkMode: "isolated"` (default) or
|
||||
`dokploy-sandboxes` for `networkMode: "internet"`. Both are created with
|
||||
`com.docker.network.bridge.enable_icc=false`, so sandboxes cannot reach
|
||||
each other even on the same bridge (a network created by an earlier build
|
||||
without that option is recreated automatically once no sandbox is attached
|
||||
to it). Sandboxes are never attached to `dokploy-network` or to any
|
||||
application network, and nothing is published through Traefik.
|
||||
- Labels `dokploy.sandbox=true`, `dokploy.sandboxId`, `dokploy.projectId` and
|
||||
`dokploy.environmentId` so the reaper/reconcile can find orphans.
|
||||
- Commands run through `docker exec` with a per-command timeout; on timeout the
|
||||
whole process tree started by that exec is killed (matched by a marker env
|
||||
var, no `procps` needed). If that kill cannot run (for example the command
|
||||
exhausted `PidsLimit`, so nothing can fork), the container is killed and
|
||||
started again: every process is gone, files are kept and the sandbox stays
|
||||
`running`. Only when the restart itself fails is the sandbox marked `error`.
|
||||
|
||||
Environment variables passed at creation are stored encrypted at rest and are
|
||||
never returned by the API.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- No in-container agent: everything goes through `docker exec`, `putArchive`
|
||||
and `getArchive`. There is no PTY / interactive shell, no stdin, and no
|
||||
process management beyond the per-command timeout.
|
||||
- Plain Docker runtime only (`runtime` column reserved for gVisor/Kata later).
|
||||
- No snapshots, pause/resume, warm pools, or billing.
|
||||
- No public domains or exposed ports.
|
||||
- Remote servers reuse the existing Dokploy SSH Docker client, which opens an
|
||||
SSH session per Docker API request (same behaviour as the rest of Dokploy);
|
||||
a pooled connection would make bursts of small operations faster.
|
||||
- `listFiles` needs GNU `find` for metadata; busybox images get names only.
|
||||
- Output of a single command is capped at 1 MiB per stream, files at 10 MiB.
|
||||
- Sandboxes are not shown in the environment services grid; they live in the
|
||||
*Sandboxes* view of the environment.
|
||||
@@ -65,6 +65,7 @@
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"hostinger-api-sdk": "^0.0.17",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.18",
|
||||
@@ -85,7 +86,6 @@
|
||||
"shell-quote": "^1.8.4",
|
||||
"slugify": "^1.6.6",
|
||||
"ssh2": "~1.16.0",
|
||||
"tar-stream": "3.2.1",
|
||||
"toml": "3.0.0",
|
||||
"ws": "8.16.0",
|
||||
"yaml": "2.8.1",
|
||||
@@ -106,7 +106,6 @@
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/tar-stream": "3.1.4",
|
||||
"@types/ws": "8.5.10",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"esbuild": "0.20.2",
|
||||
|
||||
@@ -94,5 +94,4 @@ export type AuditResourceType =
|
||||
| "compose"
|
||||
| "network"
|
||||
| "vaultProvider"
|
||||
| "dnsProvider"
|
||||
| "sandbox";
|
||||
| "dnsProvider";
|
||||
|
||||
@@ -11,7 +11,6 @@ import { mysql } from "./mysql";
|
||||
import { postgres } from "./postgres";
|
||||
import { projects } from "./project";
|
||||
import { redis } from "./redis";
|
||||
import { sandboxes } from "./sandbox";
|
||||
import { encryptedText } from "./utils";
|
||||
|
||||
export const environments = pgTable("environment", {
|
||||
@@ -46,7 +45,6 @@ export const environmentRelations = relations(
|
||||
mysql: many(mysql),
|
||||
postgres: many(postgres),
|
||||
redis: many(redis),
|
||||
sandboxes: many(sandboxes),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from "./gitea";
|
||||
export * from "./github";
|
||||
export * from "./gitlab";
|
||||
export * from "./libsql";
|
||||
export * from "./managed-server";
|
||||
export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
@@ -32,7 +33,6 @@ export * from "./redirects";
|
||||
export * from "./redis";
|
||||
export * from "./registry";
|
||||
export * from "./rollbacks";
|
||||
export * from "./sandbox";
|
||||
export * from "./schedule";
|
||||
export * from "./scim";
|
||||
export * from "./security";
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { server } from "./server";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
|
||||
export const managedServerStatus = pgEnum("managedServerStatus", [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
"ready",
|
||||
"error",
|
||||
"terminating",
|
||||
"terminated",
|
||||
]);
|
||||
|
||||
export const managedServer = pgTable("managed_server", {
|
||||
managedServerId: text("managedServerId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** UpCloud plan name, e.g. "2xCPU-4GB" */
|
||||
plan: text("plan").notNull(),
|
||||
status: managedServerStatus("status").notNull().default("pending"),
|
||||
/** UpCloud server UUID */
|
||||
providerVmId: text("providerVmId"),
|
||||
/** UpCloud zone ID, e.g. "fi-hel1" */
|
||||
zone: text("zone").notNull(),
|
||||
/** SSH key used to access this server */
|
||||
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
ipAddress: text("ipAddress"),
|
||||
hostname: text("hostname"),
|
||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||
stripePriceId: text("stripePriceId"),
|
||||
errorMessage: text("errorMessage"),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
updatedAt: text("updatedAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
export const managedServerRelations = relations(managedServer, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [managedServer.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [managedServer.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const apiCreateManagedServer = z.object({
|
||||
plan: z.string().min(1),
|
||||
zone: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiFindOneManagedServer = z.object({
|
||||
managedServerId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiDeleteManagedServer = z.object({
|
||||
managedServerId: z.string().min(1),
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
real,
|
||||
text,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { environments } from "./environment";
|
||||
import { server } from "./server";
|
||||
import { encryptedText } from "./utils";
|
||||
|
||||
export const sandboxStatus = pgEnum("sandboxStatus", [
|
||||
"creating",
|
||||
"running",
|
||||
"killed",
|
||||
"error",
|
||||
]);
|
||||
|
||||
export const sandboxNetworkMode = pgEnum("sandboxNetworkMode", [
|
||||
"isolated",
|
||||
"internet",
|
||||
]);
|
||||
|
||||
export const SANDBOX_TEMPLATE_NAMES = ["base", "python", "node"] as const;
|
||||
export type SandboxTemplateName = (typeof SANDBOX_TEMPLATE_NAMES)[number];
|
||||
|
||||
export const SANDBOX_DEFAULTS = {
|
||||
cpu: 1,
|
||||
memoryMb: 512,
|
||||
pidsLimit: 256,
|
||||
timeoutMs: 300_000,
|
||||
workdir: "/home/user",
|
||||
execTimeoutMs: 60_000,
|
||||
} as const;
|
||||
|
||||
export const sandboxes = pgTable("sandbox", {
|
||||
sandboxId: text("sandboxId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
environmentId: text("environmentId")
|
||||
.notNull()
|
||||
.references(() => environments.environmentId, { onDelete: "cascade" }),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
image: text("image").notNull(),
|
||||
template: text("template"),
|
||||
containerId: text("containerId"),
|
||||
status: sandboxStatus("status").notNull().default("creating"),
|
||||
cpu: real("cpu").notNull().default(SANDBOX_DEFAULTS.cpu),
|
||||
memoryMb: integer("memoryMb").notNull().default(SANDBOX_DEFAULTS.memoryMb),
|
||||
pidsLimit: integer("pidsLimit").notNull().default(SANDBOX_DEFAULTS.pidsLimit),
|
||||
timeoutMs: integer("timeoutMs").notNull().default(SANDBOX_DEFAULTS.timeoutMs),
|
||||
expiresAt: timestamp("expiresAt", { withTimezone: true }),
|
||||
lastActivityAt: timestamp("lastActivityAt", { withTimezone: true }),
|
||||
networkMode: sandboxNetworkMode("networkMode").notNull().default("isolated"),
|
||||
envVars: encryptedText("envVars"),
|
||||
workdir: text("workdir").notNull().default(SANDBOX_DEFAULTS.workdir),
|
||||
user: text("user"),
|
||||
runtime: text("runtime"),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
killedAt: timestamp("killedAt", { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const sandboxRelations = relations(sandboxes, ({ one }) => ({
|
||||
environment: one(environments, {
|
||||
fields: [sandboxes.environmentId],
|
||||
references: [environments.environmentId],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [sandboxes.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const absolutePath = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4096)
|
||||
.refine(
|
||||
(value) => value.startsWith("/") && !value.includes("\0"),
|
||||
"Path must be absolute",
|
||||
);
|
||||
|
||||
export const apiCreateSandbox = z
|
||||
.object({
|
||||
environmentId: z.string().min(1),
|
||||
name: z.string().min(1).max(64).optional(),
|
||||
template: z.enum(SANDBOX_TEMPLATE_NAMES).optional(),
|
||||
image: z.string().min(1).max(255).optional(),
|
||||
serverId: z.string().nullable().optional(),
|
||||
cpu: z.number().min(0.1).max(64).optional(),
|
||||
memoryMb: z.number().int().min(64).max(262_144).optional(),
|
||||
pidsLimit: z.number().int().min(16).max(65_536).optional(),
|
||||
timeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(10_000)
|
||||
.max(24 * 60 * 60 * 1000)
|
||||
.optional(),
|
||||
networkMode: z.enum(["isolated", "internet"]).optional(),
|
||||
envVars: z.string().max(65_536).optional(),
|
||||
workdir: absolutePath.optional(),
|
||||
})
|
||||
.refine((value) => !!value.template || !!value.image, {
|
||||
message: "Either template or image is required",
|
||||
path: ["image"],
|
||||
});
|
||||
|
||||
export const apiFindOneSandbox = z.object({
|
||||
sandboxId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiListSandboxes = z
|
||||
.object({
|
||||
environmentId: z.string().min(1).optional(),
|
||||
projectId: z.string().min(1).optional(),
|
||||
})
|
||||
.refine((value) => !!value.environmentId || !!value.projectId, {
|
||||
message: "Either environmentId or projectId is required",
|
||||
path: ["environmentId"],
|
||||
});
|
||||
|
||||
export const apiExecSandbox = z.object({
|
||||
sandboxId: z.string().min(1),
|
||||
cmd: z.string().min(1).max(65_536),
|
||||
cwd: absolutePath.optional(),
|
||||
env: z
|
||||
.record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), z.string())
|
||||
.optional(),
|
||||
timeoutMs: z.number().int().min(1000).max(600_000).optional(),
|
||||
});
|
||||
|
||||
export const apiWriteFileSandbox = z.object({
|
||||
sandboxId: z.string().min(1),
|
||||
path: absolutePath,
|
||||
content: z.string(),
|
||||
encoding: z.enum(["utf8", "base64"]).default("utf8"),
|
||||
mode: z.number().int().min(0).max(0o7777).optional(),
|
||||
});
|
||||
|
||||
export const apiReadFileSandbox = z.object({
|
||||
sandboxId: z.string().min(1),
|
||||
path: absolutePath,
|
||||
encoding: z.enum(["utf8", "base64"]).default("utf8"),
|
||||
});
|
||||
|
||||
export const apiListFilesSandbox = z.object({
|
||||
sandboxId: z.string().min(1),
|
||||
path: absolutePath.optional(),
|
||||
});
|
||||
|
||||
export const apiSetTimeoutSandbox = z.object({
|
||||
sandboxId: z.string().min(1),
|
||||
timeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(10_000)
|
||||
.max(24 * 60 * 60 * 1000),
|
||||
});
|
||||
@@ -22,7 +22,6 @@ import { mysql } from "./mysql";
|
||||
import { network } from "./network";
|
||||
import { postgres } from "./postgres";
|
||||
import { redis } from "./redis";
|
||||
import { sandboxes } from "./sandbox";
|
||||
import { schedules } from "./schedule";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { generateAppName } from "./utils";
|
||||
@@ -126,7 +125,6 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
||||
mongo: many(mongo),
|
||||
mysql: many(mysql),
|
||||
postgres: many(postgres),
|
||||
sandboxes: many(sandboxes),
|
||||
certificates: many(certificates),
|
||||
networks: many(network),
|
||||
organization: one(organization, {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
userName: string;
|
||||
planName: string;
|
||||
daysRemaining: number;
|
||||
endsOn: string;
|
||||
billingUrl: string;
|
||||
};
|
||||
|
||||
export const TrialExpiringEmail = ({
|
||||
userName = "User",
|
||||
planName = "Hobby",
|
||||
daysRemaining = 3,
|
||||
endsOn = "2024-01-01",
|
||||
billingUrl = "https://app.dokploy.com/dashboard/settings/billing",
|
||||
}: TemplateProps) => {
|
||||
const dayLabel = daysRemaining === 1 ? "day" : "days";
|
||||
const previewText =
|
||||
daysRemaining === 1
|
||||
? "Your Dokploy trial ends tomorrow"
|
||||
: `Your Dokploy trial ends in ${daysRemaining} ${dayLabel}`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||
<Img
|
||||
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||
width="190"
|
||||
height="120"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="bg-white px-[40px] py-[32px]">
|
||||
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||
{daysRemaining === 1
|
||||
? "Your trial ends tomorrow"
|
||||
: `Your trial ends in ${daysRemaining} ${dayLabel}`}
|
||||
</Heading>
|
||||
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||
Hello {userName}, add a payment method to keep your servers and
|
||||
applications running once the trial ends.
|
||||
</Text>
|
||||
|
||||
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
|
||||
<Row className="bg-[#fafafa]">
|
||||
<Column className="px-[20px] py-[14px] w-[50%]">
|
||||
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||
Plan
|
||||
</Text>
|
||||
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
|
||||
{planName}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="px-[20px] py-[14px] w-[50%]">
|
||||
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||
Trial ends
|
||||
</Text>
|
||||
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
|
||||
{endsOn}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section className="bg-[#fefce8] border border-solid border-[#fef08a] rounded-lg px-[20px] py-[16px] mb-[24px]">
|
||||
<Text className="text-[#854d0e] text-[13px] leading-[20px] m-0">
|
||||
Without a payment method your subscription is cancelled when
|
||||
the trial ends and your servers are deactivated.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="text-center mb-[24px]">
|
||||
<Button
|
||||
href={billingUrl}
|
||||
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
|
||||
>
|
||||
Add Payment Method
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-[#e4e4e7] my-[24px]" />
|
||||
|
||||
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||
You are billed only when the trial ends. You can cancel at any
|
||||
time before then.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
|
||||
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||
This is an automated email from{" "}
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
className="text-[#71717a] underline"
|
||||
>
|
||||
Dokploy Cloud
|
||||
</Link>
|
||||
. If you have any questions about your billing, please contact
|
||||
our{" "}
|
||||
<Link
|
||||
href="https://discord.gg/2tBnJ3jDJc"
|
||||
className="text-[#71717a] underline"
|
||||
>
|
||||
support team
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrialExpiringEmail;
|
||||
@@ -48,9 +48,9 @@ export * from "./services/redirect";
|
||||
export * from "./services/redis";
|
||||
export * from "./services/registry";
|
||||
export * from "./services/rollbacks";
|
||||
export * from "./services/sandbox";
|
||||
export * from "./services/schedule";
|
||||
export * from "./services/security";
|
||||
export * from "./services/managed-server";
|
||||
export * from "./services/server";
|
||||
export * from "./services/server-health";
|
||||
export * from "./services/settings";
|
||||
@@ -132,12 +132,6 @@ export * from "./utils/providers/github";
|
||||
export * from "./utils/providers/github";
|
||||
export * from "./utils/providers/gitlab";
|
||||
export * from "./utils/providers/raw";
|
||||
export * from "./utils/sandbox/container";
|
||||
export * from "./utils/sandbox/exec";
|
||||
export * from "./utils/sandbox/files";
|
||||
export * from "./utils/sandbox/reaper";
|
||||
export * from "./utils/sandbox/tar";
|
||||
export * from "./utils/sandbox/templates";
|
||||
export * from "./utils/schedules/index";
|
||||
export * from "./utils/schedules/utils";
|
||||
export * from "./utils/servers/remote-docker";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { managedServer } from "@dokploy/server/db/schema/managed-server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type ManagedServer = typeof managedServer.$inferSelect;
|
||||
|
||||
export const createManagedServer = async (
|
||||
input: typeof managedServer.$inferInsert,
|
||||
) => {
|
||||
const record = await db
|
||||
.insert(managedServer)
|
||||
.values(input)
|
||||
.returning()
|
||||
.then((r) => r[0]);
|
||||
if (!record) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
return record;
|
||||
};
|
||||
|
||||
export const findManagedServerById = async (managedServerId: string) => {
|
||||
const record = await db.query.managedServer.findFirst({
|
||||
where: eq(managedServer.managedServerId, managedServerId),
|
||||
with: { server: true },
|
||||
});
|
||||
if (!record)
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Managed server not found" });
|
||||
return record;
|
||||
};
|
||||
|
||||
export const findManagedServersByOrg = async (organizationId: string) => {
|
||||
return db.query.managedServer.findMany({
|
||||
where: eq(managedServer.organizationId, organizationId),
|
||||
with: { server: true },
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
});
|
||||
};
|
||||
|
||||
export const updateManagedServer = async (
|
||||
managedServerId: string,
|
||||
data: Partial<typeof managedServer.$inferInsert>,
|
||||
) => {
|
||||
return db
|
||||
.update(managedServer)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(managedServer.managedServerId, managedServerId))
|
||||
.returning()
|
||||
.then((r) => r[0]);
|
||||
};
|
||||
|
||||
export const deleteManagedServer = async (managedServerId: string) => {
|
||||
return db
|
||||
.delete(managedServer)
|
||||
.where(eq(managedServer.managedServerId, managedServerId));
|
||||
};
|
||||
@@ -1,509 +0,0 @@
|
||||
import { posix } from "node:path";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateSandbox,
|
||||
environments,
|
||||
SANDBOX_DEFAULTS,
|
||||
sandboxes,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type Dockerode from "dockerode";
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
buildSandboxContainerOptions,
|
||||
getSandboxNetworkName,
|
||||
parseSandboxUser,
|
||||
SANDBOX_LABEL,
|
||||
type SandboxNetworkMode,
|
||||
} from "../utils/sandbox/container";
|
||||
import { runSandboxExec, type SandboxExecResult } from "../utils/sandbox/exec";
|
||||
import {
|
||||
buildSandboxFindCommand,
|
||||
buildSandboxLsCommand,
|
||||
parseSandboxFindOutput,
|
||||
parseSandboxLsOutput,
|
||||
} from "../utils/sandbox/files";
|
||||
import {
|
||||
buildSandboxDirectoryTar,
|
||||
buildSandboxFileTar,
|
||||
extractSandboxFile,
|
||||
SandboxFileTooLargeError,
|
||||
SandboxNotRegularFileError,
|
||||
} from "../utils/sandbox/tar";
|
||||
import { SANDBOX_TEMPLATES } from "../utils/sandbox/templates";
|
||||
|
||||
export type Sandbox = typeof sandboxes.$inferSelect;
|
||||
|
||||
export const SANDBOX_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const INTERNAL_EXEC_TIMEOUT_MS = 15_000;
|
||||
|
||||
const statusCodeOf = (error: unknown) =>
|
||||
(error as { statusCode?: number } | null)?.statusCode;
|
||||
|
||||
const isNotFound = (error: unknown) => statusCodeOf(error) === 404;
|
||||
|
||||
export const findSandboxById = async (sandboxId: string) => {
|
||||
const result = await db.query.sandboxes.findFirst({
|
||||
where: eq(sandboxes.sandboxId, sandboxId),
|
||||
with: {
|
||||
environment: { with: { project: true } },
|
||||
server: { columns: { serverId: true, name: true, serverStatus: true } },
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Sandbox not found" });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const findSandboxesByEnvironmentId = (environmentId: string) =>
|
||||
db.query.sandboxes.findMany({
|
||||
where: eq(sandboxes.environmentId, environmentId),
|
||||
orderBy: desc(sandboxes.createdAt),
|
||||
with: { server: { columns: { serverId: true, name: true } } },
|
||||
});
|
||||
|
||||
export const findSandboxesByProjectId = async (projectId: string) => {
|
||||
const projectEnvironments = await db.query.environments.findMany({
|
||||
where: eq(environments.projectId, projectId),
|
||||
columns: { environmentId: true },
|
||||
});
|
||||
if (projectEnvironments.length === 0) return [];
|
||||
return db.query.sandboxes.findMany({
|
||||
where: inArray(
|
||||
sandboxes.environmentId,
|
||||
projectEnvironments.map((env) => env.environmentId),
|
||||
),
|
||||
orderBy: desc(sandboxes.createdAt),
|
||||
with: { server: { columns: { serverId: true, name: true } } },
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSandboxById = async (
|
||||
sandboxId: string,
|
||||
data: Partial<Sandbox>,
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(sandboxes)
|
||||
.set(data)
|
||||
.where(eq(sandboxes.sandboxId, sandboxId))
|
||||
.returning();
|
||||
return result;
|
||||
};
|
||||
|
||||
export const removeSandboxById = async (sandboxId: string) => {
|
||||
const [result] = await db
|
||||
.delete(sandboxes)
|
||||
.where(eq(sandboxes.sandboxId, sandboxId))
|
||||
.returning();
|
||||
return result;
|
||||
};
|
||||
|
||||
export const ensureSandboxImage = async (docker: Dockerode, image: string) => {
|
||||
try {
|
||||
await docker.getImage(image).inspect();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
docker.pull(image, {}, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
docker.modem.followProgress(stream, (progressError: Error | null) => {
|
||||
if (progressError) reject(progressError);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Sandboxes share a bridge per server, so inter-container traffic must be
|
||||
// disabled or two isolated sandboxes could talk to each other.
|
||||
export const SANDBOX_NETWORK_OPTIONS = {
|
||||
"com.docker.network.bridge.enable_icc": "false",
|
||||
};
|
||||
|
||||
export const ensureSandboxNetwork = async (
|
||||
docker: Dockerode,
|
||||
mode: SandboxNetworkMode,
|
||||
) => {
|
||||
const name = getSandboxNetworkName(mode);
|
||||
let existing: {
|
||||
Options?: Record<string, string>;
|
||||
Containers?: Record<string, unknown>;
|
||||
} | null = null;
|
||||
try {
|
||||
existing = await docker.getNetwork(name).inspect();
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error;
|
||||
}
|
||||
if (existing) {
|
||||
if (
|
||||
existing.Options?.["com.docker.network.bridge.enable_icc"] === "false"
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
if (Object.keys(existing.Containers ?? {}).length > 0) {
|
||||
console.warn(
|
||||
`[Sandbox] Network ${name} allows inter-container traffic but is in use; it will be recreated once no sandbox is attached`,
|
||||
);
|
||||
return name;
|
||||
}
|
||||
await docker.getNetwork(name).remove();
|
||||
}
|
||||
try {
|
||||
await docker.createNetwork({
|
||||
Name: name,
|
||||
Driver: "bridge",
|
||||
Internal: mode === "isolated",
|
||||
CheckDuplicate: true,
|
||||
Options: { ...SANDBOX_NETWORK_OPTIONS },
|
||||
Labels: { [SANDBOX_LABEL]: "true" },
|
||||
});
|
||||
} catch (error) {
|
||||
// Another create raced us; the network exists now.
|
||||
if (statusCodeOf(error) !== 409) throw error;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const resolveSandboxUser = async (
|
||||
docker: Dockerode,
|
||||
containerId: string,
|
||||
fallback: string | null,
|
||||
) => {
|
||||
try {
|
||||
const result = await runSandboxExec(docker, containerId, {
|
||||
cmd: "id -u && id -g",
|
||||
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
|
||||
});
|
||||
const [uid, gid] = result.stdout.trim().split(/\s+/);
|
||||
if (result.exitCode === 0 && uid && gid) return `${uid}:${gid}`;
|
||||
} catch {}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const prepareSandboxWorkdir = async (
|
||||
docker: Dockerode,
|
||||
containerId: string,
|
||||
workdir: string,
|
||||
user: string | null,
|
||||
) => {
|
||||
const parent = posix.dirname(workdir);
|
||||
const name = posix.basename(workdir);
|
||||
if (!name) return;
|
||||
if (parent !== "/") {
|
||||
await runSandboxExec(docker, containerId, {
|
||||
cmd: `mkdir -p ${quote([parent])}`,
|
||||
user: "root",
|
||||
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
|
||||
}).catch(() => {});
|
||||
}
|
||||
const ids = parseSandboxUser(user) ?? { uid: 0, gid: 0 };
|
||||
// putArchive runs in the daemon, so it can create the directory owned by the
|
||||
// sandbox user even though every capability (CAP_CHOWN included) is dropped.
|
||||
const tar = await buildSandboxDirectoryTar({ name, ...ids });
|
||||
await docker.getContainer(containerId).putArchive(tar, { path: parent });
|
||||
};
|
||||
|
||||
const startSandboxContainer = async (sandbox: Sandbox, projectId: string) => {
|
||||
const docker = await getRemoteDocker(sandbox.serverId);
|
||||
await ensureSandboxImage(docker, sandbox.image);
|
||||
await ensureSandboxNetwork(docker, sandbox.networkMode);
|
||||
const container = await docker.createContainer(
|
||||
buildSandboxContainerOptions({ ...sandbox, projectId }),
|
||||
);
|
||||
try {
|
||||
await container.start();
|
||||
const user = await resolveSandboxUser(docker, container.id, sandbox.user);
|
||||
await prepareSandboxWorkdir(docker, container.id, sandbox.workdir, user);
|
||||
const now = new Date();
|
||||
return await updateSandboxById(sandbox.sandboxId, {
|
||||
containerId: container.id,
|
||||
user,
|
||||
status: "running",
|
||||
expiresAt: new Date(now.getTime() + sandbox.timeoutMs),
|
||||
lastActivityAt: now,
|
||||
});
|
||||
} catch (error) {
|
||||
await container.remove({ force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createSandbox = async (
|
||||
input: z.infer<typeof apiCreateSandbox>,
|
||||
) => {
|
||||
const environment = await db.query.environments.findFirst({
|
||||
where: eq(environments.environmentId, input.environmentId),
|
||||
columns: { environmentId: true, projectId: true },
|
||||
});
|
||||
if (!environment) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const template = input.template ? SANDBOX_TEMPLATES[input.template] : null;
|
||||
const image = input.image ?? template?.image;
|
||||
if (!image) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Either template or image is required",
|
||||
});
|
||||
}
|
||||
|
||||
const [sandbox] = await db
|
||||
.insert(sandboxes)
|
||||
.values({
|
||||
name: input.name ?? `sandbox-${Date.now().toString(36)}`,
|
||||
environmentId: input.environmentId,
|
||||
serverId: input.serverId ?? null,
|
||||
image,
|
||||
template: input.template ?? null,
|
||||
cpu: input.cpu ?? SANDBOX_DEFAULTS.cpu,
|
||||
memoryMb: input.memoryMb ?? SANDBOX_DEFAULTS.memoryMb,
|
||||
pidsLimit: input.pidsLimit ?? SANDBOX_DEFAULTS.pidsLimit,
|
||||
timeoutMs: input.timeoutMs ?? SANDBOX_DEFAULTS.timeoutMs,
|
||||
networkMode: input.networkMode ?? "isolated",
|
||||
envVars: input.envVars ?? null,
|
||||
workdir: input.workdir ?? template?.workdir ?? SANDBOX_DEFAULTS.workdir,
|
||||
user: template?.user ?? null,
|
||||
status: "creating",
|
||||
})
|
||||
.returning();
|
||||
if (!sandbox) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error inserting sandbox",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const running = await startSandboxContainer(sandbox, environment.projectId);
|
||||
if (!running) throw new Error("Sandbox row disappeared during creation");
|
||||
return running;
|
||||
} catch (error) {
|
||||
await updateSandboxById(sandbox.sandboxId, { status: "error" });
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error creating sandbox: ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type RunningSandbox = Sandbox & { containerId: string };
|
||||
|
||||
export const assertSandboxRunning: (
|
||||
sandbox: Sandbox,
|
||||
) => asserts sandbox is RunningSandbox = (sandbox) => {
|
||||
if (sandbox.status !== "running" || !sandbox.containerId) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: `Sandbox is ${sandbox.status}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const touchSandbox = async (sandbox: Sandbox) => {
|
||||
const now = new Date();
|
||||
return updateSandboxById(sandbox.sandboxId, {
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + sandbox.timeoutMs),
|
||||
});
|
||||
};
|
||||
|
||||
export interface ExecInSandboxOptions {
|
||||
cmd: string;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string) => void;
|
||||
}
|
||||
|
||||
export const execInSandbox = async (
|
||||
sandbox: Sandbox,
|
||||
options: ExecInSandboxOptions,
|
||||
): Promise<SandboxExecResult> => {
|
||||
assertSandboxRunning(sandbox);
|
||||
await touchSandbox(sandbox);
|
||||
const docker = await getRemoteDocker(sandbox.serverId);
|
||||
const result = await runSandboxExec(docker, sandbox.containerId, {
|
||||
cmd: options.cmd,
|
||||
cwd: options.cwd ?? sandbox.workdir,
|
||||
env: options.env,
|
||||
timeoutMs: options.timeoutMs ?? SANDBOX_DEFAULTS.execTimeoutMs,
|
||||
onStdout: options.onStdout,
|
||||
onStderr: options.onStderr,
|
||||
});
|
||||
if (result.containerKilled && !result.containerRestarted) {
|
||||
await updateSandboxById(sandbox.sandboxId, { status: "error" });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const writeSandboxFile = async (
|
||||
sandbox: Sandbox,
|
||||
options: { path: string; content: Buffer; mode?: number },
|
||||
) => {
|
||||
assertSandboxRunning(sandbox);
|
||||
if (options.content.length > SANDBOX_MAX_FILE_BYTES) {
|
||||
throw new TRPCError({
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
message: `File exceeds the ${SANDBOX_MAX_FILE_BYTES} byte limit`,
|
||||
});
|
||||
}
|
||||
const dir = posix.dirname(options.path);
|
||||
const name = posix.basename(options.path);
|
||||
if (!name) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Path must point to a file",
|
||||
});
|
||||
}
|
||||
await touchSandbox(sandbox);
|
||||
const docker = await getRemoteDocker(sandbox.serverId);
|
||||
const mkdir = await runSandboxExec(docker, sandbox.containerId, {
|
||||
cmd: `mkdir -p ${quote([dir])}`,
|
||||
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
|
||||
});
|
||||
if (mkdir.exitCode !== 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: mkdir.stderr.trim() || `Unable to create directory ${dir}`,
|
||||
});
|
||||
}
|
||||
const ids = parseSandboxUser(sandbox.user) ?? { uid: 0, gid: 0 };
|
||||
const tar = await buildSandboxFileTar({
|
||||
name,
|
||||
content: options.content,
|
||||
mode: options.mode,
|
||||
...ids,
|
||||
});
|
||||
await docker.getContainer(sandbox.containerId).putArchive(tar, { path: dir });
|
||||
return { path: options.path, size: options.content.length };
|
||||
};
|
||||
|
||||
export const readSandboxFile = async (sandbox: Sandbox, path: string) => {
|
||||
assertSandboxRunning(sandbox);
|
||||
await touchSandbox(sandbox);
|
||||
const docker = await getRemoteDocker(sandbox.serverId);
|
||||
let archive: NodeJS.ReadableStream;
|
||||
try {
|
||||
archive = await docker
|
||||
.getContainer(sandbox.containerId)
|
||||
.getArchive({ path });
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const { content } = await extractSandboxFile(
|
||||
archive,
|
||||
SANDBOX_MAX_FILE_BYTES,
|
||||
);
|
||||
return { path, size: content.length, content };
|
||||
} catch (error) {
|
||||
if (error instanceof SandboxFileTooLargeError) {
|
||||
throw new TRPCError({
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
if (error instanceof SandboxNotRegularFileError) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const listSandboxFiles = async (sandbox: Sandbox, path?: string) => {
|
||||
assertSandboxRunning(sandbox);
|
||||
await touchSandbox(sandbox);
|
||||
const target = path ?? sandbox.workdir;
|
||||
const docker = await getRemoteDocker(sandbox.serverId);
|
||||
const find = await runSandboxExec(docker, sandbox.containerId, {
|
||||
cmd: buildSandboxFindCommand(target),
|
||||
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
|
||||
maxOutputBytes: 4 * 1024 * 1024,
|
||||
});
|
||||
if (find.exitCode === 0) {
|
||||
return { path: target, entries: parseSandboxFindOutput(find.stdout) };
|
||||
}
|
||||
// busybox find has no -printf; fall back to a name-only listing.
|
||||
const ls = await runSandboxExec(docker, sandbox.containerId, {
|
||||
cmd: buildSandboxLsCommand(target),
|
||||
timeoutMs: INTERNAL_EXEC_TIMEOUT_MS,
|
||||
maxOutputBytes: 4 * 1024 * 1024,
|
||||
});
|
||||
if (ls.exitCode === 0) {
|
||||
return { path: target, entries: parseSandboxLsOutput(ls.stdout) };
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: (ls.stderr || find.stderr).trim() || "Unable to list files",
|
||||
});
|
||||
};
|
||||
|
||||
export const setSandboxTimeout = async (
|
||||
sandbox: Sandbox,
|
||||
timeoutMs: number,
|
||||
) => {
|
||||
assertSandboxRunning(sandbox);
|
||||
const now = new Date();
|
||||
return updateSandboxById(sandbox.sandboxId, {
|
||||
timeoutMs,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + timeoutMs),
|
||||
});
|
||||
};
|
||||
|
||||
export const removeSandboxContainer = async (
|
||||
serverId: string | null,
|
||||
containerId: string,
|
||||
) => {
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
const container = docker.getContainer(containerId);
|
||||
await container.stop({ t: 2 }).catch((error: unknown) => {
|
||||
// 304 = already stopped, 404 = already gone
|
||||
if (statusCodeOf(error) !== 304 && !isNotFound(error)) throw error;
|
||||
});
|
||||
await container.remove({ force: true }).catch((error: unknown) => {
|
||||
if (!isNotFound(error)) throw error;
|
||||
});
|
||||
};
|
||||
|
||||
export const killSandbox = async (sandboxId: string) => {
|
||||
const sandbox = await findSandboxById(sandboxId);
|
||||
if (sandbox.status === "killed") return sandbox;
|
||||
if (sandbox.containerId) {
|
||||
try {
|
||||
await removeSandboxContainer(sandbox.serverId, sandbox.containerId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Sandbox] Failed to remove container for ${sandboxId}:`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return updateSandboxById(sandboxId, {
|
||||
status: "killed",
|
||||
killedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
export const removeSandbox = async (sandboxId: string) => {
|
||||
await killSandbox(sandboxId);
|
||||
return removeSandboxById(sandboxId);
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
|
||||
export const createSshKey = async (input: z.infer<typeof apiCreateSshKey>) => {
|
||||
await db.transaction(async (tx) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const sshKey = await tx
|
||||
.insert(sshKeys)
|
||||
.values(input)
|
||||
|
||||
@@ -497,6 +497,7 @@ export interface ComposeSpecification {
|
||||
*/
|
||||
include?: DefinitionsInclude[];
|
||||
services?: PropertiesServices;
|
||||
models?: PropertiesModels;
|
||||
networks?: PropertiesNetworks;
|
||||
volumes?: PropertiesVolumes;
|
||||
secrets?: PropertiesSecrets;
|
||||
@@ -648,6 +649,15 @@ export interface DefinitionsService {
|
||||
mem_swappiness?: number;
|
||||
memswap_limit?: number | string;
|
||||
network_mode?: string;
|
||||
models?:
|
||||
| ListOfStrings
|
||||
| {
|
||||
[k: string]: {
|
||||
endpoint_var?: string;
|
||||
model_var?: string;
|
||||
[key: `x-${string}`]: unknown;
|
||||
} | null;
|
||||
};
|
||||
networks?:
|
||||
| ListOfStrings
|
||||
| {
|
||||
@@ -877,3 +887,13 @@ export interface DefinitionsConfig {
|
||||
*/
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface PropertiesModels {
|
||||
[k: string]: DefinitionsModel;
|
||||
}
|
||||
export interface DefinitionsModel {
|
||||
name?: string;
|
||||
model: string;
|
||||
context_size?: number;
|
||||
runtime_flags?: string[];
|
||||
[key: `x-${string}`]: unknown;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
BillingCatalogApi,
|
||||
BillingSubscriptionsApi,
|
||||
Configuration,
|
||||
VPSDataCentersApi,
|
||||
VPSVirtualMachineApi,
|
||||
} from "hostinger-api-sdk";
|
||||
|
||||
export type {
|
||||
BillingV1CatalogCatalogItemResource as HostingerCatalogItem,
|
||||
VPSV1DataCenterDataCenterResource as HostingerDataCenter,
|
||||
VPSV1VirtualMachinePurchaseRequest as HostingerPurchaseRequest,
|
||||
VPSV1VirtualMachineVirtualMachineResource as HostingerVM,
|
||||
} from "hostinger-api-sdk";
|
||||
|
||||
// Correct base URL — api.hostinger.com returns 530, developers.hostinger.com is the real gateway
|
||||
const HOSTINGER_BASE_PATH = "https://developers.hostinger.com";
|
||||
|
||||
function getConfig() {
|
||||
const apiKey = process.env.HOSTINGER_API_KEY;
|
||||
if (!apiKey) throw new Error("HOSTINGER_API_KEY is not set");
|
||||
return new Configuration({
|
||||
basePath: HOSTINGER_BASE_PATH,
|
||||
accessToken: apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
function getVmApi() {
|
||||
return new VPSVirtualMachineApi(getConfig());
|
||||
}
|
||||
|
||||
export async function getHostingerDataCenters() {
|
||||
try {
|
||||
const api = new VPSDataCentersApi(getConfig());
|
||||
const res = await api.getDataCenterListV1();
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostingerVpsCatalog() {
|
||||
const api = new BillingCatalogApi(getConfig());
|
||||
const res = await api.getCatalogItemListV1("VPS");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
const FAKE_VM_ID = 999999;
|
||||
|
||||
export async function purchaseHostingerVps(
|
||||
body: import("hostinger-api-sdk").VPSV1VirtualMachinePurchaseRequest,
|
||||
): Promise<import("hostinger-api-sdk").BillingV1OrderVirtualMachineOrderResource> {
|
||||
if (process.env.HOSTINGER_DRY_RUN === "true") {
|
||||
console.log("[hostinger dry-run] purchaseHostingerVps called, skipping real purchase");
|
||||
return {
|
||||
virtual_machine: {
|
||||
id: FAKE_VM_ID,
|
||||
hostname: body.setup.hostname,
|
||||
state: "initial",
|
||||
ipv4: [],
|
||||
subscription_id: "dry-run-sub-123",
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
const api = getVmApi();
|
||||
const res = await api.purchaseNewVirtualMachineV1(body);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getHostingerVm(vmId: number) {
|
||||
if (process.env.HOSTINGER_DRY_RUN === "true") {
|
||||
console.log(`[hostinger dry-run] getHostingerVm(${vmId})`);
|
||||
return {
|
||||
id: vmId,
|
||||
hostname: "dry-run-vm",
|
||||
state: "running",
|
||||
ipv4: [{ address: "1.2.3.4" }],
|
||||
subscription_id: "dry-run-sub-123",
|
||||
} as never;
|
||||
}
|
||||
const api = getVmApi();
|
||||
const res = await api.getVirtualMachineDetailsV1(vmId);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function stopHostingerVm(vmId: number) {
|
||||
if (process.env.HOSTINGER_DRY_RUN === "true") {
|
||||
console.log(`[hostinger dry-run] stopHostingerVm(${vmId})`);
|
||||
return;
|
||||
}
|
||||
const api = getVmApi();
|
||||
await api.stopVirtualMachineV1(vmId);
|
||||
}
|
||||
|
||||
export async function cancelHostingerSubscription(subscriptionId: string) {
|
||||
if (process.env.HOSTINGER_DRY_RUN === "true") {
|
||||
console.log(`[hostinger dry-run] cancelHostingerSubscription(${subscriptionId})`);
|
||||
return;
|
||||
}
|
||||
const api = new BillingSubscriptionsApi(getConfig());
|
||||
await api.cancelSubscriptionV1(subscriptionId, {
|
||||
reason_code: "other",
|
||||
cancel_option: "immediately",
|
||||
});
|
||||
}
|
||||
|
||||
/** Ubuntu 22.04 LTS template ID on Hostinger */
|
||||
export const UBUNTU_22_TEMPLATE_ID = 1009;
|
||||
|
||||
/**
|
||||
* Markup multiplier applied to Hostinger's catalog price to get Dokploy's user price.
|
||||
* Hostinger KVM2 = ~$24.49/mo → Dokploy charges $45/mo (~84% markup).
|
||||
*/
|
||||
const MARKUP = 1.84;
|
||||
|
||||
export interface ManagedServerPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
hostingerItemIdMonthly: string;
|
||||
hostingerItemIdAnnual: string;
|
||||
cpus: number;
|
||||
memoryMb: number;
|
||||
diskMb: number;
|
||||
bandwidthMb: number;
|
||||
/** Price in cents Hostinger charges us monthly */
|
||||
hostingerPriceCentsMonthly: number;
|
||||
/** Price in cents we charge the user monthly */
|
||||
dokployPriceCentsMonthly: number;
|
||||
/** Price in cents we charge the user annually */
|
||||
dokployPriceCentsAnnual: number;
|
||||
}
|
||||
|
||||
/** KVM plan IDs offered through Dokploy (excludes Game Panel plans) */
|
||||
const OFFERED_PLAN_IDS = [
|
||||
"hostingercom-vps-kvm1",
|
||||
"hostingercom-vps-kvm2",
|
||||
"hostingercom-vps-kvm4",
|
||||
"hostingercom-vps-kvm8",
|
||||
];
|
||||
|
||||
/**
|
||||
* Fetches live VPS plans from Hostinger catalog and applies Dokploy markup.
|
||||
* Only returns standard KVM plans (not Game Panel variants).
|
||||
*/
|
||||
export async function getManagedServerPlans(): Promise<ManagedServerPlan[]> {
|
||||
const catalog = await getHostingerVpsCatalog();
|
||||
|
||||
const plans: ManagedServerPlan[] = [];
|
||||
|
||||
for (const item of catalog) {
|
||||
if (!OFFERED_PLAN_IDS.includes(item.id ?? "")) continue;
|
||||
|
||||
const meta = item.metadata as Record<string, string> | null;
|
||||
const cpus = Number(meta?.cpus ?? 0);
|
||||
const memoryMb = Number(meta?.memory ?? 0);
|
||||
const diskMb = Number(meta?.disk_space ?? 0);
|
||||
const bandwidthMb = Number(meta?.bandwidth ?? 0);
|
||||
|
||||
const monthlyPrice = item.prices?.find(
|
||||
(p) => p.period === 1 && p.period_unit === "month",
|
||||
);
|
||||
const annualPrice = item.prices?.find(
|
||||
(p) => p.period === 1 && p.period_unit === "year",
|
||||
);
|
||||
|
||||
if (!monthlyPrice) continue;
|
||||
|
||||
const hostingerMonthly = monthlyPrice.price ?? 0;
|
||||
const hostingerAnnual = annualPrice?.price ?? hostingerMonthly * 12;
|
||||
|
||||
// Apply markup and round to nearest $0.50 (50 cents)
|
||||
const dokployMonthly = Math.ceil((hostingerMonthly * MARKUP) / 50) * 50;
|
||||
const dokployAnnual = Math.ceil((hostingerAnnual * MARKUP) / 50) * 50;
|
||||
|
||||
// Derive hostinger item IDs for monthly and annual billing
|
||||
const hostingerItemIdMonthly = monthlyPrice.id ?? `${item.id}-usd-1m`;
|
||||
const hostingerItemIdAnnual = annualPrice?.id ?? `${item.id}-usd-1y`;
|
||||
|
||||
// Map hostinger plan names to friendly names
|
||||
const friendlyNames: Record<string, string> = {
|
||||
"hostingercom-vps-kvm1": "Starter",
|
||||
"hostingercom-vps-kvm2": "Basic",
|
||||
"hostingercom-vps-kvm4": "Growth",
|
||||
"hostingercom-vps-kvm8": "Scale",
|
||||
};
|
||||
|
||||
plans.push({
|
||||
id: item.id ?? "",
|
||||
name: friendlyNames[item.id ?? ""] ?? item.name ?? item.id ?? "",
|
||||
hostingerItemIdMonthly,
|
||||
hostingerItemIdAnnual,
|
||||
cpus,
|
||||
memoryMb,
|
||||
diskMb,
|
||||
bandwidthMb,
|
||||
hostingerPriceCentsMonthly: hostingerMonthly,
|
||||
dokployPriceCentsMonthly: dokployMonthly,
|
||||
dokployPriceCentsAnnual: dokployAnnual,
|
||||
});
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
export type ManagedServerPlanId = string;
|
||||
@@ -196,7 +196,7 @@ export const sendDatabaseBackupNotifications = async ({
|
||||
`🛠Project: ${projectName}\n` +
|
||||
`⚙️Application: ${applicationName}\n` +
|
||||
`❔Type: ${databaseType}\n` +
|
||||
`📂Database Name: ${databaseName}` +
|
||||
`📂Database Name: ${databaseName}\n` +
|
||||
`🕒Date: ${date.toLocaleString()}\n` +
|
||||
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
|
||||
);
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import type Dockerode from "dockerode";
|
||||
|
||||
export const SANDBOX_LABEL = "dokploy.sandbox";
|
||||
export const SANDBOX_ID_LABEL = "dokploy.sandboxId";
|
||||
export const SANDBOX_PROJECT_LABEL = "dokploy.projectId";
|
||||
export const SANDBOX_ENVIRONMENT_LABEL = "dokploy.environmentId";
|
||||
export const SANDBOX_ISOLATED_NETWORK = "dokploy-sandboxes-isolated";
|
||||
export const SANDBOX_INTERNET_NETWORK = "dokploy-sandboxes";
|
||||
|
||||
export type SandboxNetworkMode = "isolated" | "internet";
|
||||
|
||||
export interface SandboxContainerSpec {
|
||||
sandboxId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
image: string;
|
||||
cpu: number;
|
||||
memoryMb: number;
|
||||
pidsLimit: number;
|
||||
networkMode: SandboxNetworkMode;
|
||||
envVars?: string | null;
|
||||
workdir: string;
|
||||
user?: string | null;
|
||||
}
|
||||
|
||||
export const getSandboxNetworkName = (mode: SandboxNetworkMode) =>
|
||||
mode === "internet" ? SANDBOX_INTERNET_NETWORK : SANDBOX_ISOLATED_NETWORK;
|
||||
|
||||
export const getSandboxContainerName = (sandboxId: string) =>
|
||||
`dokploy-sandbox-${sandboxId}`;
|
||||
|
||||
export const parseSandboxEnvVars = (envVars?: string | null): string[] => {
|
||||
if (!envVars) return [];
|
||||
return envVars
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#") && line.includes("="));
|
||||
};
|
||||
|
||||
export const parseSandboxUser = (
|
||||
user?: string | null,
|
||||
): { uid: number; gid: number } | null => {
|
||||
if (!user) return null;
|
||||
const [uidPart, gidPart] = user.split(":");
|
||||
const uid = Number.parseInt(uidPart ?? "", 10);
|
||||
const gid = Number.parseInt(gidPart ?? uidPart ?? "", 10);
|
||||
if (!Number.isInteger(uid) || uid < 0) return null;
|
||||
return { uid, gid: Number.isInteger(gid) && gid >= 0 ? gid : uid };
|
||||
};
|
||||
|
||||
export const buildSandboxContainerOptions = (
|
||||
spec: SandboxContainerSpec,
|
||||
): Dockerode.ContainerCreateOptions => {
|
||||
const memory = Math.round(spec.memoryMb * 1024 * 1024);
|
||||
return {
|
||||
name: getSandboxContainerName(spec.sandboxId),
|
||||
Image: spec.image,
|
||||
// [""] resets the image entrypoint so Cmd always becomes PID 1.
|
||||
Entrypoint: [""],
|
||||
Cmd: ["tail", "-f", "/dev/null"],
|
||||
WorkingDir: spec.workdir,
|
||||
Env: [`HOME=${spec.workdir}`, ...parseSandboxEnvVars(spec.envVars)],
|
||||
...(spec.user ? { User: spec.user } : {}),
|
||||
Labels: {
|
||||
[SANDBOX_LABEL]: "true",
|
||||
[SANDBOX_ID_LABEL]: spec.sandboxId,
|
||||
[SANDBOX_PROJECT_LABEL]: spec.projectId,
|
||||
[SANDBOX_ENVIRONMENT_LABEL]: spec.environmentId,
|
||||
},
|
||||
HostConfig: {
|
||||
// docker-init (tini) as PID 1 reaps the orphans left behind when a
|
||||
// timed-out command's shell is killed; `tail` would leave zombies.
|
||||
Init: true,
|
||||
NanoCpus: Math.round(spec.cpu * 1e9),
|
||||
Memory: memory,
|
||||
MemorySwap: memory,
|
||||
PidsLimit: spec.pidsLimit,
|
||||
CapDrop: ["ALL"],
|
||||
SecurityOpt: ["no-new-privileges"],
|
||||
RestartPolicy: { Name: "no" },
|
||||
LogConfig: {
|
||||
Type: "json-file",
|
||||
Config: { "max-size": "10m", "max-file": "1" },
|
||||
},
|
||||
NetworkMode: getSandboxNetworkName(spec.networkMode),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Writable } from "node:stream";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type Dockerode from "dockerode";
|
||||
import { nanoid } from "nanoid";
|
||||
import { quote } from "shell-quote";
|
||||
|
||||
export const SANDBOX_EXEC_TIMEOUT_EXIT_CODE = 124;
|
||||
export const SANDBOX_EXEC_MARKER_ENV = "DOKPLOY_EXEC_ID";
|
||||
export const SANDBOX_EXEC_MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
const KILL_GRACE_MS = 2000;
|
||||
|
||||
export interface SandboxExecOptions {
|
||||
cmd: string;
|
||||
cwd?: string;
|
||||
env?: Record<string, string> | string[];
|
||||
user?: string;
|
||||
timeoutMs: number;
|
||||
maxOutputBytes?: number;
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string) => void;
|
||||
}
|
||||
|
||||
export interface SandboxExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
timedOut: boolean;
|
||||
truncated: boolean;
|
||||
containerKilled: boolean;
|
||||
containerRestarted: boolean;
|
||||
}
|
||||
|
||||
export const toSandboxEnvArray = (env?: Record<string, string> | string[]) =>
|
||||
Array.isArray(env)
|
||||
? env
|
||||
: Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`);
|
||||
|
||||
// Finds every process started by the exec (children included) through the
|
||||
// marker env var and kills it with the shell builtin `kill`, so each
|
||||
// iteration needs a single extra pid, which matters when the command
|
||||
// exhausted PidsLimit. GNU grep reads the NUL-separated environ with -z;
|
||||
// busybox grep has no -z, so it falls back to tr.
|
||||
export const buildSandboxKillCommand = (marker: string) => {
|
||||
const pattern = quote([`${SANDBOX_EXEC_MARKER_ENV}=${marker}`]);
|
||||
const kill = 'p="${f#/proc/}"; kill -9 "${p%/environ}" 2>/dev/null';
|
||||
const withZ = `for f in /proc/[0-9]*/environ; do if grep -qzxF ${pattern} "$f" 2>/dev/null; then ${kill}; fi; done`;
|
||||
const withTr = `for f in /proc/[0-9]*/environ; do if tr '\\0' '\\n' < "$f" 2>/dev/null | grep -qxF ${pattern}; then ${kill}; fi; done`;
|
||||
return `if grep -qzF x /dev/null 2>/dev/null; [ $? -ne 2 ]; then ${withZ}; else ${withTr}; fi; true`;
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const createSink = (
|
||||
maxBytes: number,
|
||||
onChunk: ((chunk: string) => void) | undefined,
|
||||
state: { truncated: boolean },
|
||||
) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let bytes = 0;
|
||||
const sink = new Writable({
|
||||
write(chunk: Buffer, _encoding, next) {
|
||||
onChunk?.(decoder.write(chunk));
|
||||
if (bytes < maxBytes) {
|
||||
const slice = chunk.subarray(0, maxBytes - bytes);
|
||||
chunks.push(slice);
|
||||
bytes += slice.length;
|
||||
if (slice.length < chunk.length) state.truncated = true;
|
||||
} else {
|
||||
state.truncated = true;
|
||||
}
|
||||
next();
|
||||
},
|
||||
});
|
||||
return { sink, text: () => Buffer.concat(chunks).toString("utf8") };
|
||||
};
|
||||
|
||||
const killByMarker = async (
|
||||
docker: Dockerode,
|
||||
containerId: string,
|
||||
marker: string,
|
||||
) => {
|
||||
const exec = await docker.getContainer(containerId).exec({
|
||||
Cmd: ["sh", "-c", buildSandboxKillCommand(marker)],
|
||||
AttachStdout: false,
|
||||
AttachStderr: false,
|
||||
});
|
||||
await exec.start({ Detach: true });
|
||||
};
|
||||
|
||||
export const runSandboxExec = async (
|
||||
docker: Dockerode,
|
||||
containerId: string,
|
||||
options: SandboxExecOptions,
|
||||
): Promise<SandboxExecResult> => {
|
||||
const container = docker.getContainer(containerId);
|
||||
const marker = nanoid();
|
||||
const exec = await container.exec({
|
||||
Cmd: ["sh", "-c", options.cmd],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: false,
|
||||
WorkingDir: options.cwd,
|
||||
Env: [
|
||||
...toSandboxEnvArray(options.env),
|
||||
`${SANDBOX_EXEC_MARKER_ENV}=${marker}`,
|
||||
],
|
||||
...(options.user ? { User: options.user } : {}),
|
||||
});
|
||||
const stream = await exec.start({ hijack: true, stdin: false });
|
||||
|
||||
const maxBytes = options.maxOutputBytes ?? SANDBOX_EXEC_MAX_OUTPUT_BYTES;
|
||||
const state = { truncated: false };
|
||||
const stdout = createSink(maxBytes, options.onStdout, state);
|
||||
const stderr = createSink(maxBytes, options.onStderr, state);
|
||||
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
stream.once("end", resolve);
|
||||
stream.once("close", resolve);
|
||||
stream.once("error", reject);
|
||||
});
|
||||
docker.modem.demuxStream(stream, stdout.sink, stderr.sink);
|
||||
|
||||
let timedOut = false;
|
||||
let containerKilled = false;
|
||||
let containerRestarted = false;
|
||||
let timeoutTask: Promise<void> | null = null;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
timeoutTask = (async () => {
|
||||
await killByMarker(docker, containerId, marker).catch(() => {});
|
||||
const ended = await Promise.race([
|
||||
finished.then(
|
||||
() => true,
|
||||
() => true,
|
||||
),
|
||||
sleep(KILL_GRACE_MS).then(() => false),
|
||||
]);
|
||||
if (!ended) {
|
||||
// The marker kill could not run (e.g. PidsLimit exhausted, no
|
||||
// grep). Killing and starting the container again drops every
|
||||
// process while keeping the filesystem, so the sandbox stays usable.
|
||||
await container
|
||||
.kill({ signal: "SIGKILL" })
|
||||
.then(() => {
|
||||
containerKilled = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
stream.destroy();
|
||||
if (containerKilled) {
|
||||
await container
|
||||
.start()
|
||||
.then(() => {
|
||||
containerRestarted = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, options.timeoutMs);
|
||||
|
||||
try {
|
||||
await finished;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (timeoutTask) await timeoutTask;
|
||||
|
||||
let exitCode = SANDBOX_EXEC_TIMEOUT_EXIT_CODE;
|
||||
if (!timedOut) {
|
||||
const info = await exec.inspect();
|
||||
exitCode = info.ExitCode ?? 1;
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: stdout.text(),
|
||||
stderr: stderr.text(),
|
||||
exitCode,
|
||||
timedOut,
|
||||
truncated: state.truncated,
|
||||
containerKilled,
|
||||
containerRestarted,
|
||||
};
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import { quote } from "shell-quote";
|
||||
|
||||
export type SandboxFileType = "file" | "directory" | "symlink" | "other";
|
||||
|
||||
export interface SandboxFileEntry {
|
||||
name: string;
|
||||
type: SandboxFileType;
|
||||
size: number | null;
|
||||
mode: string | null;
|
||||
modifiedAt: string | null;
|
||||
}
|
||||
|
||||
export const buildSandboxFindCommand = (path: string) =>
|
||||
`find ${quote([path])} -mindepth 1 -maxdepth 1 -printf '%y\\t%s\\t%T@\\t%m\\t%f\\n'`;
|
||||
|
||||
export const buildSandboxLsCommand = (path: string) =>
|
||||
`ls -1Ap ${quote([path])}`;
|
||||
|
||||
const FIND_TYPES: Record<string, SandboxFileType> = {
|
||||
f: "file",
|
||||
d: "directory",
|
||||
l: "symlink",
|
||||
};
|
||||
|
||||
const sortEntries = (a: SandboxFileEntry, b: SandboxFileEntry) => {
|
||||
if (a.type !== b.type) {
|
||||
if (a.type === "directory") return -1;
|
||||
if (b.type === "directory") return 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
};
|
||||
|
||||
export const parseSandboxFindOutput = (stdout: string): SandboxFileEntry[] =>
|
||||
stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
const [type, size, mtime, mode, ...rest] = line.split("\t");
|
||||
const name = rest.join("\t");
|
||||
if (!name) return [];
|
||||
const seconds = Number.parseFloat(mtime ?? "");
|
||||
const bytes = Number.parseInt(size ?? "", 10);
|
||||
return [
|
||||
{
|
||||
name,
|
||||
type: FIND_TYPES[type ?? ""] ?? "other",
|
||||
size: Number.isInteger(bytes) ? bytes : null,
|
||||
mode: mode || null,
|
||||
modifiedAt: Number.isFinite(seconds)
|
||||
? new Date(Math.floor(seconds * 1000)).toISOString()
|
||||
: null,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort(sortEntries);
|
||||
|
||||
export const parseSandboxLsOutput = (stdout: string): SandboxFileEntry[] =>
|
||||
stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((entry) => ({
|
||||
name: entry.endsWith("/") ? entry.slice(0, -1) : entry,
|
||||
type: (entry.endsWith("/") ? "directory" : "file") as SandboxFileType,
|
||||
size: null,
|
||||
mode: null,
|
||||
modifiedAt: null,
|
||||
}))
|
||||
.sort(sortEntries);
|
||||
@@ -1,188 +0,0 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { sandboxes, server } from "@dokploy/server/db/schema";
|
||||
import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker";
|
||||
import type Dockerode from "dockerode";
|
||||
import { and, eq, inArray, isNotNull, isNull, lte } from "drizzle-orm";
|
||||
import { killSandbox } from "../../services/sandbox";
|
||||
import { SANDBOX_ID_LABEL, SANDBOX_LABEL } from "./container";
|
||||
|
||||
export const SANDBOX_REAPER_INTERVAL_MS = 15_000;
|
||||
const STUCK_CREATING_MS = 10 * 60 * 1000;
|
||||
|
||||
let reaperTimer: NodeJS.Timeout | null = null;
|
||||
let reaping = false;
|
||||
|
||||
export const findExpiredSandboxes = <
|
||||
T extends { status: string; expiresAt: Date | null },
|
||||
>(
|
||||
list: T[],
|
||||
now: Date,
|
||||
) =>
|
||||
list.filter(
|
||||
(sandbox) =>
|
||||
sandbox.status === "running" &&
|
||||
!!sandbox.expiresAt &&
|
||||
sandbox.expiresAt.getTime() <= now.getTime(),
|
||||
);
|
||||
|
||||
export const reapExpiredSandboxes = async (
|
||||
now = new Date(),
|
||||
kill: (sandboxId: string) => Promise<unknown> = killSandbox,
|
||||
) => {
|
||||
const expired = await db.query.sandboxes.findMany({
|
||||
where: and(eq(sandboxes.status, "running"), lte(sandboxes.expiresAt, now)),
|
||||
columns: { sandboxId: true, status: true, expiresAt: true },
|
||||
});
|
||||
const killed: string[] = [];
|
||||
for (const sandbox of findExpiredSandboxes(expired, now)) {
|
||||
try {
|
||||
await kill(sandbox.sandboxId);
|
||||
killed.push(sandbox.sandboxId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Sandbox] Failed to reap ${sandbox.sandboxId}:`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return killed;
|
||||
};
|
||||
|
||||
export interface SandboxReconcilePlan {
|
||||
removeContainers: string[];
|
||||
markError: string[];
|
||||
}
|
||||
|
||||
export const planSandboxReconcile = (
|
||||
rows: { sandboxId: string; containerId: string | null; status: string }[],
|
||||
containers: { Id: string; State: string; Labels: Record<string, string> }[],
|
||||
): SandboxReconcilePlan => {
|
||||
const rowsById = new Map(rows.map((row) => [row.sandboxId, row]));
|
||||
const liveContainerIds = new Set<string>();
|
||||
const removeContainers: string[] = [];
|
||||
|
||||
for (const container of containers) {
|
||||
const sandboxId = container.Labels?.[SANDBOX_ID_LABEL];
|
||||
const row = sandboxId ? rowsById.get(sandboxId) : undefined;
|
||||
// A sandbox mid-creation has no containerId yet; leave it to createSandbox.
|
||||
if (row?.status === "creating") continue;
|
||||
if (
|
||||
!row ||
|
||||
row.containerId !== container.Id ||
|
||||
container.State !== "running"
|
||||
) {
|
||||
removeContainers.push(container.Id);
|
||||
continue;
|
||||
}
|
||||
liveContainerIds.add(container.Id);
|
||||
}
|
||||
|
||||
const markError = rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.status === "running" &&
|
||||
(!row.containerId || !liveContainerIds.has(row.containerId)),
|
||||
)
|
||||
.map((row) => row.sandboxId);
|
||||
|
||||
return { removeContainers, markError };
|
||||
};
|
||||
|
||||
const listSandboxContainers = (docker: Dockerode) =>
|
||||
docker.listContainers({
|
||||
all: true,
|
||||
filters: { label: [`${SANDBOX_LABEL}=true`] },
|
||||
});
|
||||
|
||||
const reconcileServer = async (serverId: string | null) => {
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
const [containers, rows] = await Promise.all([
|
||||
listSandboxContainers(docker),
|
||||
db.query.sandboxes.findMany({
|
||||
where: and(
|
||||
inArray(sandboxes.status, ["running", "creating"]),
|
||||
serverId
|
||||
? eq(sandboxes.serverId, serverId)
|
||||
: isNull(sandboxes.serverId),
|
||||
),
|
||||
columns: { sandboxId: true, containerId: true, status: true },
|
||||
}),
|
||||
]);
|
||||
const plan = planSandboxReconcile(rows, containers);
|
||||
|
||||
for (const containerId of plan.removeContainers) {
|
||||
await docker
|
||||
.getContainer(containerId)
|
||||
.remove({ force: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
if (plan.markError.length > 0) {
|
||||
await db
|
||||
.update(sandboxes)
|
||||
.set({ status: "error" })
|
||||
.where(inArray(sandboxes.sandboxId, plan.markError));
|
||||
}
|
||||
return plan;
|
||||
};
|
||||
|
||||
export const reconcileSandboxes = async () => {
|
||||
const stuckSince = new Date(Date.now() - STUCK_CREATING_MS);
|
||||
await db
|
||||
.update(sandboxes)
|
||||
.set({ status: "error" })
|
||||
.where(
|
||||
and(
|
||||
eq(sandboxes.status, "creating"),
|
||||
lte(sandboxes.createdAt, stuckSince.toISOString()),
|
||||
),
|
||||
);
|
||||
|
||||
const serverIds: (string | null)[] = IS_CLOUD ? [] : [null];
|
||||
const remoteServers = await db.query.server.findMany({
|
||||
where: isNotNull(server.sshKeyId),
|
||||
columns: { serverId: true },
|
||||
});
|
||||
serverIds.push(...remoteServers.map((remote) => remote.serverId));
|
||||
|
||||
for (const serverId of serverIds) {
|
||||
try {
|
||||
const plan = await reconcileServer(serverId);
|
||||
if (plan.removeContainers.length || plan.markError.length) {
|
||||
console.log(
|
||||
`[Sandbox] Reconciled ${serverId ?? "local"}: removed ${plan.removeContainers.length} containers, errored ${plan.markError.length} sandboxes`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Sandbox] Reconcile failed for ${serverId ?? "local"}:`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const initSandboxReaper = async () => {
|
||||
if (reaperTimer) return;
|
||||
await reconcileSandboxes();
|
||||
reaperTimer = setInterval(async () => {
|
||||
if (reaping) return;
|
||||
reaping = true;
|
||||
try {
|
||||
await reapExpiredSandboxes();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Sandbox] Reaper failed:",
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
} finally {
|
||||
reaping = false;
|
||||
}
|
||||
}, SANDBOX_REAPER_INTERVAL_MS);
|
||||
reaperTimer.unref();
|
||||
};
|
||||
|
||||
export const stopSandboxReaper = () => {
|
||||
if (reaperTimer) clearInterval(reaperTimer);
|
||||
reaperTimer = null;
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import { extract, pack } from "tar-stream";
|
||||
|
||||
export interface SandboxTarFile {
|
||||
name: string;
|
||||
content: Buffer;
|
||||
mode?: number;
|
||||
uid?: number;
|
||||
gid?: number;
|
||||
}
|
||||
|
||||
export interface SandboxTarDirectory {
|
||||
name: string;
|
||||
mode?: number;
|
||||
uid?: number;
|
||||
gid?: number;
|
||||
}
|
||||
|
||||
const toBuffer = (chunk: unknown) =>
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array | string);
|
||||
|
||||
const collect = async (stream: AsyncIterable<unknown>) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(toBuffer(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
};
|
||||
|
||||
export const buildSandboxFileTar = async (file: SandboxTarFile) => {
|
||||
const tar = pack();
|
||||
tar.entry(
|
||||
{
|
||||
name: file.name,
|
||||
type: "file",
|
||||
size: file.content.length,
|
||||
mode: file.mode ?? 0o644,
|
||||
uid: file.uid ?? 0,
|
||||
gid: file.gid ?? 0,
|
||||
mtime: new Date(),
|
||||
},
|
||||
file.content,
|
||||
);
|
||||
tar.finalize();
|
||||
return collect(tar);
|
||||
};
|
||||
|
||||
export const buildSandboxDirectoryTar = async (dir: SandboxTarDirectory) => {
|
||||
const tar = pack();
|
||||
tar.entry({
|
||||
name: dir.name.endsWith("/") ? dir.name : `${dir.name}/`,
|
||||
type: "directory",
|
||||
mode: dir.mode ?? 0o755,
|
||||
uid: dir.uid ?? 0,
|
||||
gid: dir.gid ?? 0,
|
||||
mtime: new Date(),
|
||||
});
|
||||
tar.finalize();
|
||||
return collect(tar);
|
||||
};
|
||||
|
||||
export class SandboxFileTooLargeError extends Error {
|
||||
constructor(maxBytes: number) {
|
||||
super(`File exceeds the ${maxBytes} byte limit`);
|
||||
this.name = "SandboxFileTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SandboxNotRegularFileError extends Error {
|
||||
constructor() {
|
||||
super("Path is not a regular file");
|
||||
this.name = "SandboxNotRegularFileError";
|
||||
}
|
||||
}
|
||||
|
||||
export const extractSandboxFile = (
|
||||
source: NodeJS.ReadableStream,
|
||||
maxBytes: number,
|
||||
): Promise<{ name: string; content: Buffer }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const extractor = extract();
|
||||
let found: { name: string; content: Buffer } | null = null;
|
||||
let settled = false;
|
||||
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
extractor.destroy();
|
||||
(source as Readable).destroy?.();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
let first = true;
|
||||
extractor.on("entry", (header, entryStream, next) => {
|
||||
// Docker returns the requested path as the first entry; a directory
|
||||
// tar would otherwise leak its first file as if it were the target.
|
||||
if (first && header.type !== "file") {
|
||||
fail(new SandboxNotRegularFileError());
|
||||
return;
|
||||
}
|
||||
first = false;
|
||||
if (found || header.type !== "file") {
|
||||
entryStream.on("end", next);
|
||||
entryStream.resume();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
entryStream.on("data", (data: unknown) => {
|
||||
const chunk = toBuffer(data);
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) {
|
||||
fail(new SandboxFileTooLargeError(maxBytes));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
entryStream.on("end", () => {
|
||||
found = { name: header.name, content: Buffer.concat(chunks) };
|
||||
next();
|
||||
});
|
||||
});
|
||||
extractor.on("finish", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (found) resolve(found);
|
||||
else reject(new SandboxNotRegularFileError());
|
||||
});
|
||||
extractor.on("error", fail);
|
||||
source.on("error", fail);
|
||||
source.pipe(extractor);
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { SandboxTemplateName } from "@dokploy/server/db/schema";
|
||||
|
||||
export interface SandboxTemplate {
|
||||
image: string;
|
||||
user: string | null;
|
||||
workdir: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Placeholder images until the dokploy/sandbox-* images are published.
|
||||
export const SANDBOX_TEMPLATES: Record<SandboxTemplateName, SandboxTemplate> = {
|
||||
base: {
|
||||
image: "ubuntu:24.04",
|
||||
user: "1000:1000",
|
||||
workdir: "/home/user",
|
||||
description: "Ubuntu 24.04 with a POSIX shell and coreutils",
|
||||
},
|
||||
python: {
|
||||
image: "python:3.12-slim",
|
||||
user: "1000:1000",
|
||||
workdir: "/home/user",
|
||||
description: "Python 3.12 (pip available)",
|
||||
},
|
||||
node: {
|
||||
image: "node:22-slim",
|
||||
user: "1000:1000",
|
||||
workdir: "/home/user",
|
||||
description: "Node.js 22 (npm available)",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import { defaultCommand } from "../setup/server-setup";
|
||||
|
||||
const UPCLOUD_BASE = "https://api.upcloud.com/1.3";
|
||||
const UBUNTU_22_TEMPLATE_UUID = "01000000-0000-4000-8000-000030220200";
|
||||
|
||||
export { UBUNTU_22_TEMPLATE_UUID };
|
||||
|
||||
export interface DokployPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
priceCents: number;
|
||||
upcloudPlan: string;
|
||||
cpus: number;
|
||||
memoryGb: number;
|
||||
storageGb: number;
|
||||
}
|
||||
|
||||
export const DOKPLOY_PLANS: DokployPlan[] = [
|
||||
{
|
||||
id: "hobby",
|
||||
name: "Hobby",
|
||||
priceCents: 2499,
|
||||
upcloudPlan: "1xCPU-2GB",
|
||||
cpus: 1,
|
||||
memoryGb: 2,
|
||||
storageGb: 50,
|
||||
},
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
priceCents: 4900,
|
||||
upcloudPlan: "2xCPU-4GB",
|
||||
cpus: 2,
|
||||
memoryGb: 4,
|
||||
storageGb: 80,
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
priceCents: 9900,
|
||||
upcloudPlan: "4xCPU-8GB",
|
||||
cpus: 4,
|
||||
memoryGb: 8,
|
||||
storageGb: 160,
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
priceCents: 19900,
|
||||
upcloudPlan: "HIMEM-4xCPU-16GB",
|
||||
cpus: 4,
|
||||
memoryGb: 16,
|
||||
storageGb: 320,
|
||||
},
|
||||
];
|
||||
|
||||
function getHeaders() {
|
||||
const token = process.env.UPCLOUD_TOKEN;
|
||||
if (!token) throw new Error("UPCLOUD_TOKEN is not set");
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function upcloudFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${UPCLOUD_BASE}${path}`, {
|
||||
...options,
|
||||
headers: { ...getHeaders(), ...(options.headers ?? {}) },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = await res.text();
|
||||
}
|
||||
const err = new Error(
|
||||
`UpCloud API error ${res.status}: ${JSON.stringify(body)}`,
|
||||
) as Error & { response: { status: number; data: unknown } };
|
||||
err.response = { status: res.status, data: body };
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export interface UpCloudZone {
|
||||
id: string;
|
||||
description: string;
|
||||
public: "yes" | "no";
|
||||
}
|
||||
|
||||
export type UpCloudZoneContinent = "Europe" | "Americas" | "Asia-Pacific";
|
||||
|
||||
export interface UpCloudZoneWithContinent extends UpCloudZone {
|
||||
continent: UpCloudZoneContinent;
|
||||
}
|
||||
|
||||
const ZONE_CONTINENT: Record<string, UpCloudZoneContinent> = {
|
||||
"fi-hel1": "Europe",
|
||||
"fi-hel2": "Europe",
|
||||
"de-fra1": "Europe",
|
||||
"nl-ams1": "Europe",
|
||||
"uk-lon1": "Europe",
|
||||
"se-sto1": "Europe",
|
||||
"dk-cph1": "Europe",
|
||||
"no-svg1": "Europe",
|
||||
"pl-waw1": "Europe",
|
||||
"es-mad1": "Europe",
|
||||
"us-nyc1": "Americas",
|
||||
"us-chi1": "Americas",
|
||||
"us-sjo1": "Americas",
|
||||
"sg-sin1": "Asia-Pacific",
|
||||
"au-syd1": "Asia-Pacific",
|
||||
};
|
||||
|
||||
export async function getUpCloudZones(): Promise<UpCloudZoneWithContinent[]> {
|
||||
const data = await upcloudFetch<{ zones: { zone: UpCloudZone[] } }>("/zone");
|
||||
return data.zones.zone
|
||||
.filter((z) => z.public === "yes" && z.id in ZONE_CONTINENT)
|
||||
.map((z) => ({ ...z, continent: ZONE_CONTINENT[z.id]! }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.continent.localeCompare(b.continent) ||
|
||||
a.description.localeCompare(b.description),
|
||||
);
|
||||
}
|
||||
|
||||
export interface UpCloudServer {
|
||||
uuid: string;
|
||||
hostname: string;
|
||||
title: string;
|
||||
state: "started" | "stopped" | "maintenance" | "error";
|
||||
zone: string;
|
||||
plan: string;
|
||||
core_number: string;
|
||||
memory_amount: string;
|
||||
ip_addresses: {
|
||||
ip_address: Array<{
|
||||
access: "public" | "utility" | "private";
|
||||
address: string;
|
||||
family: "IPv4" | "IPv6";
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function createUpCloudServer(params: {
|
||||
hostname: string;
|
||||
upcloudPlan: string;
|
||||
zone: string;
|
||||
sshKey: string;
|
||||
}): Promise<UpCloudServer> {
|
||||
const userDataScript = `#!/bin/bash\n${defaultCommand(false)}`;
|
||||
|
||||
const body = {
|
||||
server: {
|
||||
zone: params.zone,
|
||||
title: params.hostname,
|
||||
hostname: params.hostname,
|
||||
plan: params.upcloudPlan,
|
||||
firewall: "on",
|
||||
metadata: "yes",
|
||||
user_data: userDataScript,
|
||||
login_user: {
|
||||
username: "root",
|
||||
ssh_keys: {
|
||||
ssh_key: [params.sshKey],
|
||||
},
|
||||
create_password: "no",
|
||||
},
|
||||
storage_devices: {
|
||||
storage_device: [
|
||||
{
|
||||
action: "clone",
|
||||
storage: UBUNTU_22_TEMPLATE_UUID,
|
||||
title: `${params.hostname}-disk`,
|
||||
tier: "maxiops",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const data = await upcloudFetch<{ server: UpCloudServer }>("/server", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return data.server;
|
||||
}
|
||||
|
||||
export async function getUpCloudServer(uuid: string): Promise<UpCloudServer> {
|
||||
const data = await upcloudFetch<{ server: UpCloudServer }>(`/server/${uuid}`);
|
||||
return data.server;
|
||||
}
|
||||
|
||||
export async function stopUpCloudServer(uuid: string): Promise<void> {
|
||||
await upcloudFetch(`/server/${uuid}/stop`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ stop_server: { stop_type: "hard", timeout: "60" } }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUpCloudServer(uuid: string): Promise<void> {
|
||||
await upcloudFetch(`/server/${uuid}?storages=1&backups=delete`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function getPublicIPv4(server: UpCloudServer): string | undefined {
|
||||
return server.ip_addresses.ip_address.find(
|
||||
(ip) => ip.access === "public" && ip.family === "IPv4",
|
||||
)?.address;
|
||||
}
|
||||
@@ -79,6 +79,12 @@ const readPath = async (
|
||||
// still reports success. Single secrets read via /raw/{name} expand by
|
||||
// default, which makes the difference easy to miss in the UI.
|
||||
expandSecretReferences: "true",
|
||||
// Secrets pulled in through "Import Secrets" are not part of `secrets`:
|
||||
// they come back in a separate `imports` array, and only when this is
|
||||
// asked for. Note the snake_case — `includeImports` is silently ignored
|
||||
// (the request still returns 200 with an empty `imports`), which is why
|
||||
// an imported secret looked like it simply did not exist.
|
||||
include_imports: "true",
|
||||
});
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`,
|
||||
@@ -93,9 +99,17 @@ const readPath = async (
|
||||
|
||||
const body = (await response.json()) as {
|
||||
secrets?: { secretKey: string; secretValue: string }[];
|
||||
imports?: { secrets?: { secretKey: string; secretValue: string }[] }[];
|
||||
};
|
||||
|
||||
const secrets: Record<string, string> = {};
|
||||
// Imported first, then the folder's own: Infisical resolves a name defined
|
||||
// in both in favour of the local one, so writing local last preserves that.
|
||||
for (const imported of body.imports ?? []) {
|
||||
for (const secret of imported.secrets ?? []) {
|
||||
secrets[secret.secretKey] = secret.secretValue;
|
||||
}
|
||||
}
|
||||
for (const secret of body.secrets ?? []) {
|
||||
secrets[secret.secretKey] = secret.secretValue;
|
||||
}
|
||||
|
||||
Generated
+256
-391
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user